Repository: google/exposure-notifications-server
Pull request #718 (2020-07-14 19:17:31)

Implement ExposureKey revisions
* Publish API changes
 * Transform still validates the incoming request
 * If any of the uploaded keys have previously been seen, join and figure out changes
 * perform upsert over the new keys

More work on #663

google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/718#" title="Author self-approved">mikehelmick</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [mikehelmick]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-oss-robot: @mikehelmick: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-en-server-release-unit | 6e09db7614380fcf85ad1762153257a1e63e289b | [link](https://oss-prow.knative.dev/view/gcs/oss-prow/pr-logs/pull/google_exposure-notifications-server/718/pull-en-server-release-unit/1283177750915452929) | `/test pull-en-server-release-unit`

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
mikehelmick: /retest
sadly , we still have that flaky test

Patch:
diff --git a/docs/getting-started/publishing-temporary-exposure-keys.md b/docs/getting-started/publishing-temporary-exposure-keys.md
index 121166f..5bd295d 100644
--- a/docs/getting-started/publishing-temporary-exposure-keys.md
+++ b/docs/getting-started/publishing-temporary-exposure-keys.md
@@ -28,24 +28,19 @@ The structure of the API is defined in [pkg/api/v1alpha1/exposure_types.go](http
 in the `Publish` type. Please see the documentation in the source file
 for details of the fields themselves.
 
-Here, we point out some non-obvious validation that is applied to the keys.
-
-* All keys must be valid! If there are any validation errors, the entire batch
-  is rejected.
-* Max keys per publish: Default is `20` and can be adjusted with the
-  `MAX_KEYS_ON_PUBLISH` environment variable.
-* Max overlapping keys with same start interval: Default is `3` and can be
-  adjusted with the `MAX_SAME_START_INTERVAL_KEYS` environment variable.
-	In practical terms, this means that if you are obtaining TEK history on a
-	mobile device with >= v1.5 of the device API, it will stop the validity
-	of the current day's TEK and issue a new now. Both keys will have the same
-	start iterval.
-* Max age: How old keys can be. The default is `360h` (15 days) and can be
-  adjusted with the `MAX_INTERVAL_AGE_ON_PUBLISH`. All provided keys must have
-	a `rollingStartNumber` that is >= to the max age.
+Here, we point out some non-obvious validation that is applied to the keys. All keys must be valid! If there are any validation errors, the entire batch is rejected.
+
+* The following are configurable variables for validating payloads:
+	| Environment Variable         | Description          | Default |
+	|------------------------------|----------------------|---------|
+	| MAX_KEYS_ON_PUBLISH          | Max keys per publish | 20      |
+	| MAX_SAME_START_INTERVAL_KEYS | Max overlapping keys with same start interval. In practical terms, this means that if you are obtaining TEK history on a mobile device with >= v1.5 of the device API, it will stop the validity of the current day's TEK and issue a new now. Both keys will have the same start interval. |  3  |
+	| MAX_INTERVAL_AGE_ON_PUBLISH  | Max age. How old keys can be. All provided keys must have a `rollingStartNumber` that is >= to the max age. | 360h (15 days)   |
+
+In addition to the above configurations,
 * Keys with a future start time (`rollingStartNumber` indicates time > now),
   are rejected.
-* Keys that are "sill valid" are accepted by the server, but they are embargoed
+* Keys that are "still valid" are accepted by the server, but they are embargoed
   until after they key could no longer be replayed usefully. A stall valid key
 	is one where the `rollingStartNumber` is in the past, but the
 	`rollingStartNumber` + the `rollingPeriod` indicates a future time.
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index 18a03ae..91fd186 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -57,7 +57,7 @@ var (
 
 type (
 	fetchFn               func(context.Context, *pb.FederationFetchRequest, ...grpc.CallOption) (*pb.FederationFetchResponse, error)
-	insertExposuresFn     func(context.Context, []*publishmodel.Exposure) error
+	insertExposuresFn     func(context.Context, []*publishmodel.Exposure) (int, error)
 	startFederationSyncFn func(context.Context, *model.FederationInQuery, time.Time) (int64, database.FinalizeSyncFn, error)
 )
 
@@ -192,7 +192,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	opts := pullOptions{
 		deps: pullDependencies{
 			fetch:               client.Fetch,
-			insertExposures:     h.publishdb.InsertExposures,
+			insertExposures:     h.publishdb.InsertAndReviseExposures,
 			startFederationSync: h.db.StartFederationInSync,
 		},
 		query:          query,
@@ -292,22 +292,24 @@ func pull(ctx context.Context, metrics metrics.Exporter, opts *pullOptions) (err
 					})
 
 					if len(exposures) == fetchBatchSize {
-						if err := opts.deps.insertExposures(ctx, exposures); err != nil {
+						n := 0
+						if n, err = opts.deps.insertExposures(ctx, exposures); err != nil {
 							metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 							return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 						}
-						total += len(exposures)
+						total += n
 						exposures = nil // Start a new batch.
 					}
 				}
 			}
 		}
 		if len(exposures) > 0 {
-			if err := opts.deps.insertExposures(ctx, exposures); err != nil {
+			n := 0
+			if n, err = opts.deps.insertExposures(ctx, exposures); err != nil {
 				metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 				return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 			}
-			total += len(exposures)
+			total += n
 		}
 
 		partial = response.PartialResponse
diff --git a/internal/generate/handler.go b/internal/generate/handler.go
index 89313b7..e6f2fda 100644
--- a/internal/generate/handler.go
+++ b/internal/generate/handler.go
@@ -121,7 +121,7 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		err = h.database.InsertExposures(ctx, exposures)
+		n, err := h.database.InsertAndReviseExposures(ctx, exposures)
 		if err != nil {
 			message := fmt.Sprintf("error writing exposure record: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
@@ -129,7 +129,7 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			fmt.Fprint(w, message)
 			return
 		}
-		logger.Infof("Generated %v exposures", len(exposures))
+		logger.Infof("Generated %v exposures", n)
 	}
 	w.WriteHeader(http.StatusOK)
 	fmt.Fprint(w, "Generated exposure keys.")
diff --git a/internal/publish/database/exposure.go b/internal/publish/database/exposure.go
index 1dc3168..fd0e33c 100644
--- a/internal/publish/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -27,6 +27,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
 
 	pgx "github.com/jackc/pgx/v4"
@@ -203,23 +204,20 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 // ReadExposures will read an existing set of exposures from the database.
 // This is necessary in case a key needs to be revised.
 // In the return map, the key is the base64 of the ExposureKey.
-func (db *PublishDB) ReadExposures(ctx context.Context, b64keys []string) (map[string]*model.Exposure, error) {
-	conn, err := db.db.Pool.Acquire(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("acquiring connection: %v", err)
-	}
-	defer conn.Release()
-
+// The keys are read for update in a provided transaction.
+func (db *PublishDB) ReadExposures(ctx context.Context, tx pgx.Tx, b64keys []string) (map[string]*model.Exposure, error) {
 	query := `
 		SELECT
 			exposure_key, transmission_risk, app_package_name, regions,
 			interval_number, interval_count, created_at, local_provenance, sync_id,
 			health_authority_id, report_type, days_since_symptom_onset,
-			revised_report_type, revised_at, revised_days_since_symptom_onset
+			revised_report_type, revised_at, revised_days_since_symptom_onset,
+			revised_transmission_risk
 		FROM
 			Exposure
-		WHERE exposure_key = ANY($1)`
-	rows, err := conn.Query(ctx, query, b64keys)
+		WHERE exposure_key = ANY($1)
+		FOR UPDATE`
+	rows, err := tx.Query(ctx, query, b64keys)
 	if err != nil {
 		return nil, err
 	}
@@ -241,6 +239,7 @@ func (db *PublishDB) ReadExposures(ctx context.Context, b64keys []string) (map[s
 			&exposure.CreatedAt, &exposure.LocalProvenance, &syncID,
 			&exposure.HealthAuthorityID, &exposure.ReportType, &exposure.DaysSinceSymptomOnset,
 			&exposure.RevisedReportType, &exposure.RevisedAt, &exposure.RevisedDaysSinceSymptomOnset,
+			&exposure.RevisedTransmissionRisk,
 		); err != nil {
 			return nil, err
 		}
@@ -261,44 +260,109 @@ func (db *PublishDB) ReadExposures(ctx context.Context, b64keys []string) (map[s
 	return result, nil
 }
 
-// ReviseExposures transactionally revises and inserts a set of keys as necessary.
-func (db *PublishDB) ReviseExposures(ctx context.Context, exposures []*model.Exposure) error {
-	// TODO(mikehelmick): implement revise exposures functionality.
-	return fmt.Errorf("REVISE EXPOSURES NOT YET IMPLEMENTED")
+func prepareInsertExposure(ctx context.Context, tx pgx.Tx) (string, error) {
+	const stmtName = "insert exposures"
+	_, err := tx.Prepare(ctx, stmtName, `
+		INSERT INTO
+			Exposure
+				(exposure_key, transmission_risk, app_package_name, regions, interval_number, interval_count,
+				 created_at, local_provenance, sync_id, health_authority_id, report_type, days_since_symptom_onset)
+		VALUES
+			($1, $2, LOWER($3), $4, $5, $6, $7, $8, $9, $10, $11, $12)
+		ON CONFLICT (exposure_key) DO NOTHING
+	`)
+	return stmtName, err
 }
 
-// InsertExposures inserts a set of exposures.
-func (db *PublishDB) InsertExposures(ctx context.Context, exposures []*model.Exposure) error {
-	return db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
-		const stmtName = "insert exposures"
-		_, err := tx.Prepare(ctx, stmtName, `
-			INSERT INTO
-				Exposure
-			    (exposure_key, transmission_risk, app_package_name, regions, interval_number, interval_count,
-			     created_at, local_provenance, sync_id, health_authority_id, report_type, days_since_symptom_onset)
-			VALUES
-			  ($1, $2, LOWER($3), $4, $5, $6, $7, $8, $9, $10, $11, $12)
-			ON CONFLICT (exposure_key) DO NOTHING
+func executeInsertExposure(ctx context.Context, tx pgx.Tx, stmtName string, exp *model.Exposure) error {
+	var syncID *int64
+	if exp.FederationSyncID != 0 {
+		syncID = &exp.FederationSyncID
+	}
+	_, err := tx.Exec(ctx, stmtName, encodeExposureKey(exp.ExposureKey), exp.TransmissionRisk,
+		exp.AppPackageName, exp.Regions, exp.IntervalNumber, exp.IntervalCount,
+		exp.CreatedAt, exp.LocalProvenance, syncID,
+		exp.HealthAuthorityID, exp.ReportType, exp.DaysSinceSymptomOnset)
+	if err != nil {
+		return fmt.Errorf("inserting exposure: %v", err)
+	}
+	return nil
+}
+
+func prepareReviseExposure(ctx context.Context, tx pgx.Tx) (string, error) {
+	const stmtName = "update exposures"
+	_, err := tx.Prepare(ctx, stmtName, `
+		UPDATE
+			Exposure
+		SET
+			health_authority_id = $1, revised_report_type = $2, revised_at = $3,
+			revised_days_since_symptom_onset = $4, revised_transmission_risk = $5
+		WHERE
+			exposure_key = $6 AND revised_at IS NULL
 		`)
+	return stmtName, err
+}
+
+func executeReviseExposure(ctx context.Context, tx pgx.Tx, stmtName string, exp *model.Exposure) error {
+	result, err := tx.Exec(ctx, stmtName,
+		exp.HealthAuthorityID, exp.RevisedReportType, exp.RevisedAt,
+		exp.RevisedDaysSinceSymptomOnset, exp.RevisedTransmissionRisk,
+		encodeExposureKey(exp.ExposureKey))
+	if err != nil {
+		return fmt.Errorf("revising exposure: %v", err)
+	}
+	if result.RowsAffected() != 1 {
+		return fmt.Errorf("invalid key revision request")
+	}
+	return nil
+}
+
+// InsertAndReviseExposures transactionally revises and inserts a set of keys as necessary.
+func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, incoming []*model.Exposure) (int, error) {
+	updated := 0
+	err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		// Read any existing TEKs FOR UPDATE in this transaction.
+		b64keys := make([]string, len(incoming))
+		for i, e := range incoming {
+			b64keys[i] = e.ExposureKeyBase64()
+		}
+		existing, err := db.ReadExposures(ctx, tx, b64keys)
+		if err != nil {
+			return fmt.Errorf("unable to check for existing records")
+		}
+
+		// Run through the merge logic.
+		exposures, err := model.ReviseKeys(ctx, existing, incoming)
+		if err != nil {
+			return fmt.Errorf("unable to revise keys: %w", err)
+		}
+
+		// Prepare the insert and update statements.
+		insertStmt, err := prepareInsertExposure(ctx, tx)
 		if err != nil {
 			return fmt.Errorf("preparing insert statement: %v", err)
 		}
+		updateStmt, err := prepareReviseExposure(ctx, tx)
+		if err != nil {
+			return fmt.Errorf("preparing update statement: %v", err)
+		}
 
-		for _, inf := range exposures {
-			var syncID *int64
-			if inf.FederationSyncID != 0 {
-				syncID = &inf.FederationSyncID
-			}
-			_, err := tx.Exec(ctx, stmtName, encodeExposureKey(inf.ExposureKey), inf.TransmissionRisk,
-				inf.AppPackageName, inf.Regions, inf.IntervalNumber, inf.IntervalCount,
-				inf.CreatedAt, inf.LocalProvenance, syncID,
-				inf.HealthAuthorityID, inf.ReportType, inf.DaysSinceSymptomOnset)
-			if err != nil {
-				return fmt.Errorf("inserting exposure: %v", err)
+		for _, exp := range exposures {
+			if exp.RevisedAt == nil && exp.ReportType != verifyapi.ReportTypeNegative {
+				if err := executeInsertExposure(ctx, tx, insertStmt, exp); err != nil {
+					return err
+				}
+			} else {
+				if err := executeReviseExposure(ctx, tx, updateStmt, exp); err != nil {
+					return err
+				}
 			}
 		}
+
+		updated = len(exposures)
 		return nil
 	})
+	return updated, err
 }
 
 // DeleteExposure deletes exposure
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index 49e927c..b6d4669 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -31,6 +31,13 @@ import (
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 )
 
+var (
+	ErrorExposureKeyMismatch     = fmt.Errorf("attempted to revise a key with a different key")
+	ErrorHealthAuthorityMismatch = fmt.Errorf("key revisition attempted by different health authority")
+	ErrorNonLocalProvenance      = fmt.Errorf("key not origionally uploaded to this server, cannot revise")
+	ErrorKeyAlreadyRevised       = fmt.Errorf("key has already been revised and cannot be revised again")
+)
+
 // Exposure represents the record as stored in the database
 type Exposure struct {
 	ExposureKey      []byte
@@ -45,12 +52,67 @@ type Exposure struct {
 
 	// These fileds are nullable to maintain backwards compatibility with
 	// older versions that predate their existence.
-	HealthAuthorityID            *int64
-	ReportType                   string
-	DaysSinceSymptomOnset        *int32
+	HealthAuthorityID     *int64
+	ReportType            string
+	DaysSinceSymptomOnset *int32
+
+	// Fields to support key revision.
 	RevisedReportType            *string
 	RevisedAt                    *time.Time
-	RevisedDaysSinceSymptomOnset *int
+	RevisedDaysSinceSymptomOnset *int32
+	RevisedTransmissionRisk      *int
+
+	// b64 key
+	base64Key string
+}
+
+// Revise updates the Revised fields of a key
+func (e *Exposure) Revise(in *Exposure) (bool, error) {
+	if e.ExposureKeyBase64() != in.ExposureKeyBase64() {
+		return false, ErrorExposureKeyMismatch
+	}
+	// key doesn't need to be revised if there is no change.
+	if e.ReportType == in.ReportType {
+		return false, nil
+	}
+	if !e.LocalProvenance {
+		return false, ErrorNonLocalProvenance
+	}
+	// make sure key hasn't been revised already.
+	if e.RevisedAt != nil {
+		return false, ErrorKeyAlreadyRevised
+	}
+
+	// Check to see if this is a valid transition.
+	if !(e.ReportType == verifyapi.ReportTypeClinical && (in.ReportType == verifyapi.ReportTypeConfirmed || in.ReportType == verifyapi.ReportTypeNegative)) {
+		return false, fmt.Errorf("invalid report type transition, cannot transition from '%v' to '%v'", e.ReportType, in.ReportType)
+	}
+
+	// Update fields.
+	// Key is potentially revised by a different health authority.
+	e.HealthAuthorityID = in.HealthAuthorityID
+	// If there are new regions in the incoming version, add them to the previous on.
+	// Regions are not removed however.
+	e.AddMissingRegions(in.Regions)
+	e.RevisedReportType = &in.ReportType
+	e.RevisedAt = &in.CreatedAt
+	e.RevisedDaysSinceSymptomOnset = in.DaysSinceSymptomOnset
+	e.RevisedTransmissionRisk = &in.TransmissionRisk
+
+	return true, nil
+}
+
+func (e *Exposure) AddMissingRegions(regions []string) {
+	m := make(map[string]struct{})
+	for _, r := range e.Regions {
+		m[r] = struct{}{}
+	}
+	for _, r := range regions {
+		if _, ok := m[r]; !ok {
+			m[r] = struct{}{}
+			e.Regions = append(e.Regions, r)
+		}
+	}
 }
 
 // HasDaysSinceSymptomOnset returns true if the this key has the days since
@@ -62,10 +124,7 @@ func (e *Exposure) HasDaysSinceSymptomOnset() bool {
 // SetDaysSinceSymptomOnset sets the days since sympton onset field, possibly
 // allocating a new pointer.
 func (e *Exposure) SetDaysSinceSymptomOnset(d int32) {
-	if e.DaysSinceSymptomOnset == nil {
-		e.DaysSinceSymptomOnset = new(int32)
-	}
-	*e.DaysSinceSymptomOnset = d
+	e.DaysSinceSymptomOnset = &d
 }
 
 func (e *Exposure) HasHealthAuthorityID() bool {
@@ -73,15 +132,39 @@ func (e *Exposure) HasHealthAuthorityID() bool {
 }
 
 func (e *Exposure) SetHealthAuthorityID(haID int64) {
-	if e.HealthAuthorityID == nil {
-		e.HealthAuthorityID = new(int64)
+	e.HealthAuthorityID = &haID
+}
+
+func (e *Exposure) HasBeenRevised() bool {
+	return e.RevisedAt != nil
+}
+
+func (e *Exposure) SetRevisedAt(t time.Time) error {
+	if e.RevisedAt != nil {
+		return fmt.Errorf("exposure key has already been revised and cannot be revised again")
 	}
-	*e.HealthAuthorityID = haID
+	e.RevisedAt = &t
+	return nil
+}
+
+func (e *Exposure) SetRevisedReportType(rt string) {
+	e.RevisedReportType = &rt
+}
+
+func (e *Exposure) SetRevisedDaysSinceSymptomOnset(d int32) {
+	e.RevisedDaysSinceSymptomOnset = &d
+}
+
+func (e *Exposure) SetRevisedTransmissionRisk(tr int) {
+	e.RevisedTransmissionRisk = &tr
 }
 
 // ExposureKeyBase64 returns the ExposuerKey property base64 encoded.
 func (e *Exposure) ExposureKeyBase64() string {
-	return base64.StdEncoding.EncodeToString(e.ExposureKey)
+	if e.base64Key == "" {
+		e.base64Key = base64.StdEncoding.EncodeToString(e.ExposureKey)
+	}
+	return e.base64Key
 }
 
 // ApplyTransmissionRiskOverrides modifies the transmission risk values in the publish request
@@ -264,11 +347,39 @@ func TransformExposureKey(exposureKey verifyapi.ExposureKey, appPackageName stri
 	}, nil
 }
 
-func (t *Transformer) ReviseKeys(ctx context.Context, existing map[string]*Exposure, incoming []*Exposure) ([]*Exposure, error) {
-	// TODO(mikehelmick): Implement revise keys functionality.
-	// output := make([]*Exposure, 0, len(incoming))
+// ReviseKeys takes a set of existing keys, and a list of keys currently being uploaded.
+// Only keys that need to be revsised or are being created fir the first time
+// are returned in the output set.
+func ReviseKeys(ctx context.Context, existing map[string]*Exposure, incoming []*Exposure) ([]*Exposure, error) {
+	//logger := logging.FromContext(ctx)
+	output := make([]*Exposure, 0, len(incoming))
+
+	// Iterate over incoming keys.
+	// If the key already exists
+	//  - determine if it needs to be revised, revise it, put in output.
+	//  - if it doesn't need to be revised (nochange), don't put in putput
+	// New keys, throw it in the output list. Party on.
+	for _, inExposure := range incoming {
+		prevExposure, ok := existing[inExposure.ExposureKeyBase64()]
+		if !ok {
+			output = append(output, inExposure)
+			continue
+		}
+
+		// Attempt to revise this key.
+		keyRevised, err := prevExposure.Revise(inExposure)
+		if err != nil {
+			return nil, err
+		}
+		if !keyRevised {
+			// key hasn't changed, carry on.
+			continue
+		}
+		// Revision worked, add the revised key to the output list.
+		output = append(output, prevExposure)
+	}
 
-	return nil, fmt.Errorf("KEY REVISION NOT YET IMPLEMENTED")
+	return output, nil
 }
 
 func ReportTypeTransmissionRisk(reportType string, providedTR int) int {
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 0bfacc9..7714eb3 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -169,41 +169,16 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		return response{status: http.StatusBadRequest, message: message, metric: "publish-transform-fail", count: 1}
 	}
 
-	// Before insert, load any existing keys that may be revised.
-	b64keys := make([]string, 0, len(exposures))
-	for _, e := range exposures {
-		b64keys = append(b64keys, e.ExposureKeyBase64())
-	}
-	existingExposures, err := h.database.ReadExposures(ctx, b64keys)
+	n, err := h.database.InsertAndReviseExposures(ctx, exposures)
 	if err != nil {
-		message := fmt.Sprintf("unable to read any existing key state: %v", err)
-		logger.Errorf(message)
-		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-		return response{status: http.StatusInternalServerError, message: http.StatusText(http.StatusInternalServerError), metric: "publish-db-read-error", count: 1}
-	}
-
-	var writeFn func() error
-	if len(existingExposures) == 0 {
-		writeFn = func() error { return h.database.InsertExposures(ctx, exposures) }
-	} else {
-		revised, err := h.transformer.ReviseKeys(ctx, existingExposures, exposures)
-		if err != nil {
-			message := fmt.Sprintf("unable to revise keys: %v", err)
-			logger.Errorf(message)
-			span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
-			return response{status: http.StatusBadRequest, message: message, metric: "publish-revision-failure", count: 1}
-		}
-		writeFn = func() error { return h.database.ReviseExposures(ctx, revised) }
-	}
-	if err := writeFn(); err != nil {
 		message := fmt.Sprintf("error writing exposure record: %v", err)
 		logger.Errorf(message)
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
 		return response{status: http.StatusInternalServerError, message: http.StatusText(http.StatusInternalServerError), metric: "publish-db-write-error", count: 1}
 	}
 
-	message := fmt.Sprintf("Inserted %d exposures.", len(exposures))
-	span.AddAttributes(trace.Int64Attribute("inserted_exposures", int64(len(exposures))))
+	message := fmt.Sprintf("Inserted %d exposures.", n)
+	span.AddAttributes(trace.Int64Attribute("inserted_exposures", int64(n)))
 	logger.Info(message)
 	return response{
 		status:  http.StatusOK,
diff --git a/migrations/000032_add_revised_transmission_risk.down.sql b/migrations/000032_add_revised_transmission_risk.down.sql
new file mode 100644
index 0000000..0e3ec92
--- /dev/null
+++ b/migrations/000032_add_revised_transmission_risk.down.sql
@@ -0,0 +1,20 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE exposure
+	DROP COLUMN revised_transmission_risk INT;
+
+END;
diff --git a/migrations/000032_add_revised_transmission_risk.up.sql b/migrations/000032_add_revised_transmission_risk.up.sql
new file mode 100644
index 0000000..08d7e9f
--- /dev/null
+++ b/migrations/000032_add_revised_transmission_risk.up.sql
@@ -0,0 +1,20 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE exposure
+	ADD COLUMN revised_transmission_risk INT;
+
+END;


Test patch:
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index d831bd8..21d25de 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -482,7 +482,7 @@ func TestKeysInBatch(t *testing.T) {
 	}
 
 	// Add the keys to the database.
-	if err := publishdb.New(testDB).InsertExposures(ctx, []*publishmodel.Exposure{sek, eek}); err != nil {
+	if _, err := publishdb.New(testDB).InsertAndReviseExposures(ctx, []*publishmodel.Exposure{sek, eek}); err != nil {
 		t.Fatal(err)
 	}
 
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index ece2fc3..699688f 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -70,9 +70,9 @@ type publishDB struct {
 	exposures []*publishmodel.Exposure
 }
 
-func (idb *publishDB) insertExposures(ctx context.Context, exposures []*publishmodel.Exposure) error {
+func (idb *publishDB) insertExposures(ctx context.Context, exposures []*publishmodel.Exposure) (int, error) {
 	idb.exposures = append(idb.exposures, exposures...)
-	return nil
+	return len(exposures), nil
 }
 
 // syncDB mocks the database, recording start and complete invocations for a sync record.
@@ -293,7 +293,7 @@ func TestFederationPull(t *testing.T) {
 				t.Fatalf("pull returned err=%v, want err=nil", err)
 			}
 
-			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(publishmodel.Exposure{}, "CreatedAt")); diff != "" {
+			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(publishmodel.Exposure{}, "CreatedAt"), cmpopts.IgnoreUnexported(publishmodel.Exposure{})); diff != "" {
 				t.Errorf("exposures mismatch (-want +got):\n%s", diff)
 			}
 			if diff := cmp.Diff(tc.wantTokens, remote.gotTokens); diff != "" {
diff --git a/internal/integration/publish_test.go b/internal/integration/publish_test.go
index 704d3da..91934f7 100644
--- a/internal/integration/publish_test.go
+++ b/internal/integration/publish_test.go
@@ -228,7 +228,7 @@ func alterExposureCreatedAt(t *testing.T, ctx context.Context, db *database.DB,
 			return fmt.Errorf("failed deleting exposure %v: %v", m.ExposureKey, err)
 		}
 		m.CreatedAt = m.CreatedAt.Add(timeDelta)
-		if err := publishdb.New(db).InsertExposures(ctx, []*publishmodel.Exposure{m}); err != nil {
+		if _, err := publishdb.New(db).InsertAndReviseExposures(ctx, []*publishmodel.Exposure{m}); err != nil {
 			return fmt.Errorf("failed inserting exposure %v with updated creation time: %v",
 				m.ExposureKey, err)
 		}
diff --git a/internal/publish/database/exposure_test.go b/internal/publish/database/exposure_test.go
index 41893c3..a1304e1 100644
--- a/internal/publish/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -24,14 +24,17 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-
 	"github.com/google/exposure-notifications-server/internal/publish/model"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	pgx "github.com/jackc/pgx/v4"
+
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
 var (
-	approxTime = cmp.Options{cmpopts.EquateApproxTime(time.Second)}
+	approxTime               = cmp.Options{cmpopts.EquateApproxTime(time.Second)}
+	ignoreUnexportedExposure = cmpopts.IgnoreUnexported(model.Exposure{})
 )
 
 func TestReadExposures(t *testing.T) {
@@ -61,7 +64,7 @@ func TestReadExposures(t *testing.T) {
 			LocalProvenance: true,
 		},
 	}
-	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 
@@ -70,7 +73,12 @@ func TestReadExposures(t *testing.T) {
 		keys = append(keys, e.ExposureKeyBase64())
 	}
 
-	readBack, err := testPublishDB.ReadExposures(ctx, keys)
+	var readBack map[string]*model.Exposure
+	err := testDB.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		var err error
+		readBack, err = testPublishDB.ReadExposures(ctx, tx, keys)
+		return err
+	})
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -87,7 +95,7 @@ func TestReadExposures(t *testing.T) {
 		return out
 	})
 
-	if diff := cmp.Diff(exposures, got, approxTime, sorter); diff != "" {
+	if diff := cmp.Diff(exposures, got, approxTime, sorter, ignoreUnexportedExposure); diff != "" {
 		t.Errorf("ReadExposures mismatch (-want, +got):\n%s", diff)
 	}
 }
@@ -135,7 +143,7 @@ func TestExposures(t *testing.T) {
 			LocalProvenance: false,
 		},
 	}
-	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 
@@ -185,7 +193,7 @@ func TestExposures(t *testing.T) {
 		for _, i := range test.want {
 			want = append(want, exposures[i])
 		}
-		if diff := cmp.Diff(want, got); diff != "" {
+		if diff := cmp.Diff(want, got, ignoreUnexportedExposure); diff != "" {
 			t.Errorf("%+v: mismatch (-want, +got):\n%s", test.criteria, diff)
 		}
 	}
@@ -203,7 +211,7 @@ func TestExposures(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if diff := cmp.Diff(exposures[2:], got); diff != "" {
+	if diff := cmp.Diff(exposures[2:], got, ignoreUnexportedExposure); diff != "" {
 		t.Errorf("DeleteExposuresBefore: mismatch (-want, +got):\n%s", diff)
 	}
 }
@@ -219,6 +227,103 @@ func listExposures(ctx context.Context, db *PublishDB, c IterateExposuresCriteri
 	return exps, nil
 }
 
+func TestReviseExposures(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	testDB := database.NewTestDatabase(t)
+	pubDB := New(testDB)
+
+	createdAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Hour)
+	revisedAt := time.Now().UTC().Truncate(time.Hour)
+
+	existingExp := &model.Exposure{
+		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4},
+		TransmissionRisk: verifyapi.TransmissionRiskClinical,
+		AppPackageName:   "foo.bar",
+		Regions:          []string{"US", "CA", "MX"},
+		IntervalNumber:   100,
+		IntervalCount:    144,
+		CreatedAt:        createdAt,
+		LocalProvenance:  true,
+		ReportType:       verifyapi.ReportTypeClinical,
+	}
+	existingExp.SetDaysSinceSymptomOnset(0)
+	if n, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{existingExp}); err != nil {
+		t.Fatalf("error inserting exposures: %v", err)
+	} else if n != 1 {
+		t.Fatalf("wrong number of changed exposures, want: 1, got: %v", n)
+	}
+
+	// Modify the existing on in place.
+	revisedExp := &model.Exposure{
+		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4},
+		TransmissionRisk: verifyapi.TransmissionRiskConfirmedStandard,
+		AppPackageName:   "foo.bar",
+		Regions:          []string{"US", "CA", "MX"},
+		IntervalNumber:   100,
+		IntervalCount:    144,
+		CreatedAt:        revisedAt,
+		LocalProvenance:  true,
+		ReportType:       verifyapi.ReportTypeConfirmed,
+	}
+	// Add a new TEK to insert.
+	newExp := &model.Exposure{
+		ExposureKey:      []byte{1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5},
+		TransmissionRisk: verifyapi.TransmissionRiskClinical,
+		AppPackageName:   "foo.bar",
+		Regions:          []string{"US", "CA", "MX"},
+		IntervalNumber:   244,
+		IntervalCount:    144,
+		CreatedAt:        revisedAt,
+		LocalProvenance:  true,
+		ReportType:       verifyapi.ReportTypeConfirmed,
+	}
+	newExp.SetDaysSinceSymptomOnset(1)
+
+	revisions := []*model.Exposure{revisedExp, newExp}
+	if n, err := pubDB.InsertAndReviseExposures(ctx, revisions); err != nil {
+		t.Fatalf("error revising exposures: %v", err)
+	} else if n != 2 {
+		t.Fatalf("wrong number of changed exposures, want: 2, got: %v", n)
+	}
+
+	// Read back and compare.
+	expectedKeys := []string{existingExp.ExposureKeyBase64(), newExp.ExposureKeyBase64()}
+	var got map[string]*model.Exposure
+	err := testDB.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		var err error
+		got, err = pubDB.ReadExposures(ctx, tx, expectedKeys)
+		return err
+	})
+	if err != nil {
+		t.Fatalf("error reading exposures: %v", err)
+	}
+
+	if l := len(got); l != 2 {
+		t.Fatalf("didn't read back both exposures, got: %v", l)
+	}
+
+	want := []*model.Exposure{existingExp, newExp}
+	// need to modify the exitingExp to match what we expect to get back.
+	existingExp.SetRevisedTransmissionRisk(verifyapi.TransmissionRiskConfirmedStandard)
+	existingExp.SetRevisedAt(revisedAt)
+	existingExp.SetRevisedReportType(verifyapi.ReportTypeConfirmed)
+
+	for i := 0; i <= 1; i++ {
+		if diff := cmp.Diff(want[i], got[want[i].ExposureKeyBase64()], ignoreUnexportedExposure); diff != "" {
+			t.Errorf("mismatch (-want, +got):\n%s", diff)
+		}
+	}
+
+	// Attempt to revise the already revised key.
+	if _, err := pubDB.InsertAndReviseExposures(ctx, []*model.Exposure{revisedExp}); err == nil {
+		t.Fatalf("expected error on revising already revised key, got nil")
+	} else if !strings.Contains(err.Error(), "key has already been revised ") {
+		t.Fatalf("wrong error, want 'invalid key revision request', got: %v", err)
+	}
+}
+
 func TestIterateExposuresCursor(t *testing.T) {
 	t.Parallel()
 
@@ -244,7 +349,7 @@ func TestIterateExposuresCursor(t *testing.T) {
 			Regions:        []string{"MX", "CA"},
 		},
 	}
-	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 	// Iterate over them, canceling the context in the middle.
@@ -259,7 +364,7 @@ func TestIterateExposuresCursor(t *testing.T) {
 	if !errors.Is(err, context.Canceled) {
 		t.Fatalf("got %v, wanted context.Canceled", err)
 	}
-	if diff := cmp.Diff(exposures[:2], seen); diff != "" {
+	if diff := cmp.Diff(exposures[:2], seen, ignoreUnexportedExposure); diff != "" {
 		t.Fatalf("exposures mismatch (-want, +got):\n%s", diff)
 	}
 	if want := encodeCursor("2"); cursor != want {
@@ -273,7 +378,7 @@ func TestIterateExposuresCursor(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if diff := cmp.Diff(exposures[2:], seen); diff != "" {
+	if diff := cmp.Diff(exposures[2:], seen, ignoreUnexportedExposure); diff != "" {
 		t.Fatalf("exposures mismatch (-want, +got):\n%s", diff)
 	}
 	if cursor != "" {
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index 3c98529..e9687ed 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -29,6 +29,7 @@ import (
 	"github.com/google/exposure-notifications-server/pkg/base64util"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
 const (
@@ -417,9 +418,11 @@ func TestReportTypeToTransmissionRisk(t *testing.T) {
 	}
 }
 
-func int32Ptr(v int32) *int32 { return &v }
-
-func int64Ptr(v int64) *int64 { return &v }
+func intPtr(v int) *int              { return &v }
+func int32Ptr(v int32) *int32        { return &v }
+func int64Ptr(v int64) *int64        { return &v }
+func timePtr(t time.Time) *time.Time { return &t }
+func stringPtr(s string) *string     { return &s }
 
 func TestTransform(t *testing.T) {
 	captureStartTime := time.Date(2020, 2, 29, 11, 15, 1, 0, time.UTC)
@@ -721,7 +724,7 @@ func TestTransform(t *testing.T) {
 				t.Fatalf("TransformPublish returned unexpected error: %v", err)
 			}
 
-			if diff := cmp.Diff(tc.Want, got); diff != "" {
+			if diff := cmp.Diff(tc.Want, got, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
 				t.Errorf("TransformPublish mismatch (-want +got):\n%v", diff)
 			}
 		})
@@ -1139,3 +1142,216 @@ func TestDaysFromSymptomOnset(t *testing.T) {
 		})
 	}
 }
+
+func TestReviseKeys(t *testing.T) {
+	createdAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Hour)
+	revisedAt := time.Now().UTC().Truncate(time.Hour)
+
+	allExposures := make([]*Exposure, 4)
+	// The "existing" key that isn't in the revision set.
+	allExposures[0] = &Exposure{
+		ExposureKey: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+	}
+	// Existing key that is in the revision set
+	allExposures[1] = &Exposure{
+		ExposureKey:       []byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
+		TransmissionRisk:  0,
+		Regions:           []string{"US"},
+		IntervalNumber:    7,
+		IntervalCount:     144,
+		CreatedAt:         createdAt,
+		LocalProvenance:   true,
+		HealthAuthorityID: int64Ptr(2),
+		ReportType:        verifyapi.ReportTypeClinical,
+	}
+	// New version of existing key
+	allExposures[2] = &Exposure{
+		ExposureKey:       []byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
+		TransmissionRisk:  2,
+		Regions:           []string{"US"},
+		IntervalNumber:    7,
+		IntervalCount:     144,
+		CreatedAt:         revisedAt,
+		LocalProvenance:   true,
+		HealthAuthorityID: int64Ptr(2),
+		ReportType:        verifyapi.ReportTypeConfirmed,
+	}
+	// New key not in existing set.
+	allExposures[3] = &Exposure{
+		ExposureKey:       []byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2},
+		TransmissionRisk:  0,
+		Regions:           []string{"US"},
+		IntervalNumber:    8,
+		IntervalCount:     144,
+		CreatedAt:         createdAt,
+		LocalProvenance:   true,
+		HealthAuthorityID: int64Ptr(2),
+		ReportType:        verifyapi.ReportTypeConfirmed,
+	}
+
+	ctx := context.Background()
+	existing := make(map[string]*Exposure)
+	existing[allExposures[0].ExposureKeyBase64()] = allExposures[0]
+	existing[allExposures[1].ExposureKeyBase64()] = allExposures[1]
+
+	incoming := make([]*Exposure, 2)
+	incoming[0] = allExposures[2]
+	incoming[1] = allExposures[3]
+
+	got, err := ReviseKeys(ctx, existing, incoming)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	want := []*Exposure{
+		{
+			ExposureKey:             []byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
+			TransmissionRisk:        0,
+			Regions:                 []string{"US"},
+			IntervalNumber:          7,
+			IntervalCount:           144,
+			CreatedAt:               createdAt,
+			LocalProvenance:         true,
+			HealthAuthorityID:       int64Ptr(2),
+			ReportType:              verifyapi.ReportTypeClinical,
+			RevisedAt:               &revisedAt,
+			RevisedReportType:       stringPtr(verifyapi.ReportTypeConfirmed),
+			RevisedTransmissionRisk: intPtr(2),
+		},
+		{
+			ExposureKey:       []byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2},
+			TransmissionRisk:  0,
+			Regions:           []string{"US"},
+			IntervalNumber:    8,
+			IntervalCount:     144,
+			CreatedAt:         createdAt,
+			LocalProvenance:   true,
+			HealthAuthorityID: int64Ptr(2),
+			ReportType:        verifyapi.ReportTypeConfirmed,
+		},
+	}
+
+	if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestExposureReview(t *testing.T) {
+	createdAt := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Hour)
+	revisedAt := time.Now().UTC().Add(time.Hour).Truncate(time.Hour)
+
+	cases := []struct {
+		name          string
+		previous      *Exposure
+		incoming      *Exposure
+		want          *Exposure
+		needsRevision bool
+		err           string
+	}{
+		{
+			name: "matching_report_type",
+			previous: &Exposure{
+				ReportType: verifyapi.ReportTypeConfirmed,
+			},
+			incoming: &Exposure{
+				ReportType: verifyapi.ReportTypeConfirmed,
+			},
+			needsRevision: false,
+			err:           "",
+		},
+		{
+			name: "invalid_provenance",
+			previous: &Exposure{
+				ReportType: verifyapi.ReportTypeClinical,
+			},
+			incoming: &Exposure{
+				ReportType: verifyapi.ReportTypeConfirmed,
+			},
+			needsRevision: false,
+			err:           ErrorNonLocalProvenance.Error(),
+		},
+		{
+			name: "already_revised",
+			previous: &Exposure{
+				ReportType:      verifyapi.ReportTypeClinical,
+				LocalProvenance: true,
+				RevisedAt:       timePtr(time.Now().UTC()),
+			},
+			incoming: &Exposure{
+				ReportType: verifyapi.ReportTypeConfirmed,
+			},
+			needsRevision: false,
+			err:           ErrorKeyAlreadyRevised.Error(),
+		},
+		{
+			name: "invalid_transition_confirmed_to_clinical",
+			previous: &Exposure{
+				ReportType:      verifyapi.ReportTypeConfirmed,
+				LocalProvenance: true,
+			},
+			incoming: &Exposure{
+				ReportType: verifyapi.ReportTypeClinical,
+			},
+			needsRevision: false,
+			err:           "invalid report type transition, cannot transition from 'confirmed' to 'likely'",
+		},
+		{
+			name: "revise_key",
+			previous: &Exposure{
+				ReportType:            verifyapi.ReportTypeClinical,
+				LocalProvenance:       true,
+				HealthAuthorityID:     int64Ptr(2),
+				Regions:               []string{"US", "CA"},
+				TransmissionRisk:      4,
+				CreatedAt:             createdAt,
+				DaysSinceSymptomOnset: int32Ptr(-1),
+			},
+			incoming: &Exposure{
+				ReportType:            verifyapi.ReportTypeConfirmed,
+				HealthAuthorityID:     int64Ptr(3),
+				Regions:               []string{"MX"},
+				TransmissionRisk:      5,
+				CreatedAt:             revisedAt,
+				DaysSinceSymptomOnset: int32Ptr(0),
+			},
+			want: &Exposure{
+				ReportType:                   verifyapi.ReportTypeClinical,
+				LocalProvenance:              true,
+				HealthAuthorityID:            int64Ptr(3),
+				Regions:                      []string{"US", "CA", "MX"},
+				TransmissionRisk:             4,
+				CreatedAt:                    createdAt,
+				DaysSinceSymptomOnset:        int32Ptr(-1),
+				RevisedReportType:            stringPtr(verifyapi.ReportTypeConfirmed),
+				RevisedAt:                    &revisedAt,
+				RevisedDaysSinceSymptomOnset: int32Ptr(0),
+				RevisedTransmissionRisk:      intPtr(5),
+			},
+			needsRevision: true,
+			err:           "",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := tc.previous.Revise(tc.incoming)
+			if result != tc.needsRevision {
+				t.Errorf("revision decision mismatch: want: %v got: %v", tc.needsRevision, result)
+			}
+			if err != nil && tc.err == "" {
+				t.Fatalf("unexpected error: %v", err)
+			} else if err != nil && !strings.Contains(err.Error(), tc.err) {
+				t.Fatalf("wrong error: want '%v', got: '%v'", tc.err, err)
+			} else if err == nil && tc.err != "" {
+				t.Fatalf("expected error: want '%v', got: nil", tc.err)
+			}
+			if tc.err != "" || !tc.needsRevision {
+				return
+			}
+
+			if diff := cmp.Diff(tc.want, tc.previous, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index c8878c7..d1e4006 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -486,7 +486,7 @@ func TestPublishWithBypass(t *testing.T) {
 				})
 				ignoreCreatedAt := cmpopts.IgnoreFields(*want[0], "CreatedAt")
 
-				if diff := cmp.Diff(want, got, sorter, ignoreCreatedAt); diff != "" {
+				if diff := cmp.Diff(want, got, sorter, ignoreCreatedAt, cmpopts.IgnoreUnexported(model.Exposure{})); diff != "" {
 					t.Errorf("mismatch (-want, +got):\n%s", diff)
 				}
 			} else {