Repository: google/exposure-notifications-server
Pull request #710 (2020-07-11 21:30:30)

Work on v1.5
* Add key revision columns to exposure table
* This includes capturing the health authority ID (if provided)
  * Keys can only be revised by the same health authority ID
* Implement setting of Exposure fields based on the claims from the verification certificate
  * If a ReportType is present, set it in the Exposure.ReportType
  * Optionally, backfill the transmission risk, based on the report type
* Calculate the days +/- sypmtom onset
  * the symptom onset interval can be provided in the API or from the verification certificate
* stub out key revision
  * existing keys are read from the DB if they match the input
  * TODO: merge existing and input keys
  * TODO: implement transactional key revision
* IterateExposures
  * add ability to select revised 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/710#" 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":[]} -->
sethvargo: /lgtm
/approve
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/710#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/710#issuecomment-657520609" title="Approved">sethvargo</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,sethvargo]

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":[]} -->

Patch:
diff --git a/internal/generate/config.go b/internal/generate/config.go
index bb7777f..cf0a9d2 100644
--- a/internal/generate/config.go
+++ b/internal/generate/config.go
@@ -20,6 +20,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/pkg/secrets"
 )
@@ -28,6 +29,7 @@ import (
 var _ setup.DatabaseConfigProvider = (*Config)(nil)
 var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 var _ setup.ObservabilityExporterConfigProvider = (*Config)(nil)
+var _ model.TransformerConfig = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
@@ -36,15 +38,40 @@ type Config struct {
 	SecretManager         secrets.Config
 	ObservabilityExporter observability.Config
 
-	Port                     string        `env:"PORT, default=8080"`
-	NumExposures             int           `env:"NUM_EXPOSURES_GENERATED, default=10"`
-	KeysPerExposure          int           `env:"KEYS_PER_EXPOSURE, default=14"`
-	MaxKeysOnPublish         int           `env:"MAX_KEYS_ON_PUBLISH, default=15"`
-	MaxSameStartIntervalKeys int           `env:"MAX_SAME_START_INTERVAL_KEYS, default=2"`
-	SimulateSameDayRelease   bool          `env:"SIMULATE_SAME_DAY_RELEASE, default=false"`
-	MaxIntervalAge           time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
-	TruncateWindow           time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
-	DefaultRegion            string        `env:"DEFAULT_REGOIN, default=US"`
+	Port                         string        `env:"PORT, default=8080"`
+	NumExposures                 int           `env:"NUM_EXPOSURES_GENERATED, default=10"`
+	KeysPerExposure              int           `env:"KEYS_PER_EXPOSURE, default=14"`
+	MaxKeysOnPublish             uint          `env:"MAX_KEYS_ON_PUBLISH, default=15"`
+	MaxSameStartIntervalKeys     uint          `env:"MAX_SAME_START_INTERVAL_KEYS, default=2"`
+	SimulateSameDayRelease       bool          `env:"SIMULATE_SAME_DAY_RELEASE, default=false"`
+	MaxIntervalAge               time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
+	MaxMagnitudeSymptomOnsetDays uint          `env:"MAX_SYMPTOM_ONSET_DAYS, default=21"`
+	CreatedAtTruncateWindow      time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+	DefaultRegion                string        `env:"DEFAULT_REGOIN, default=US"`
+}
+
+func (c *Config) MaxExposureKeys() uint {
+	return c.MaxKeysOnPublish
+}
+
+func (c *Config) MaxSameDayKeys() uint {
+	return c.MaxSameStartIntervalKeys
+}
+
+func (c *Config) MaxIntervalStartAge() time.Duration {
+	return c.MaxIntervalAge
+}
+
+func (c *Config) TruncateWindow() time.Duration {
+	return c.CreatedAtTruncateWindow
+}
+
+func (c *Config) MaxSymptomOnsetDays() uint {
+	return c.MaxMagnitudeSymptomOnsetDays
+}
+
+func (c *Config) DebugReleaseSameDayKeys() bool {
+	return false
 }
 
 func (c *Config) DatabaseConfig() *database.Config {
diff --git a/internal/generate/handler.go b/internal/generate/handler.go
index 8367048..89313b7 100644
--- a/internal/generate/handler.go
+++ b/internal/generate/handler.go
@@ -29,6 +29,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/util"
+	"github.com/google/exposure-notifications-server/internal/verification"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 )
 
@@ -39,7 +40,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("missing database in server environment")
 	}
 
-	transformer, err := model.NewTransformer(config.MaxKeysOnPublish, config.MaxSameStartIntervalKeys, config.MaxIntervalAge, config.TruncateWindow, false)
+	transformer, err := model.NewTransformer(config)
 	if err != nil {
 		return nil, fmt.Errorf("model.NewTransformer: %w", err)
 	}
@@ -78,12 +79,8 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	for i := 0; i < h.config.NumExposures; i++ {
 		logger.Infof("Generating exposure %v of %v", i+1, h.config.NumExposures)
 
-		tr, err := util.RandomTransmissionRisk()
-		if err != nil {
-			tr = 0
-		}
 		publish := verifyapi.Publish{
-			Keys:           util.GenerateExposureKeys(h.config.KeysPerExposure, tr, false),
+			Keys:           util.GenerateExposureKeys(h.config.KeysPerExposure, 0, false),
 			Regions:        regions,
 			AppPackageName: "generated.data",
 		}
@@ -101,7 +98,21 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
-		exposures, err := h.transformer.TransformPublish(ctx, &publish, batchTime)
+		reportType, err := util.RandomReportType()
+		if err != nil {
+			message := fmt.Sprintf("error generating report type: %v", err)
+			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
+			w.WriteHeader(http.StatusInternalServerError)
+			fmt.Fprint(w, message)
+			return
+		}
+
+		claims := verification.VerifiedClaims{
+			ReportType:           reportType,
+			SymptomOnsetInterval: uint32(publish.Keys[0].IntervalNumber),
+		}
+
+		exposures, err := h.transformer.TransformPublish(ctx, &publish, &claims, batchTime)
 		if err != nil {
 			message := fmt.Sprintf("Error transforming generated exposures: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
diff --git a/internal/integration/integration.go b/internal/integration/integration.go
index 4a83e81..9044a06 100644
--- a/internal/integration/integration.go
+++ b/internal/integration/integration.go
@@ -154,8 +154,8 @@ func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
 		MaxKeysOnPublish:         15,
 		MaxSameStartIntervalKeys: 2,
 		MaxIntervalAge:           360 * time.Hour,
-		TruncateWindow:           1 * time.Second,
-		DebugReleaseSameDayKeys:  true,
+		CreatedAtTruncateWindow:  1 * time.Second,
+		ReleaseSameDayKeys:       true,
 	}
 
 	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
diff --git a/internal/publish/config.go b/internal/publish/config.go
index a6d452d..33d6881 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -21,6 +21,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/internal/verification"
 	"github.com/google/exposure-notifications-server/pkg/secrets"
@@ -31,6 +32,7 @@ var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
 var _ setup.DatabaseConfigProvider = (*Config)(nil)
 var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 var _ setup.ObservabilityExporterConfigProvider = (*Config)(nil)
+var _ model.TransformerConfig = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
@@ -42,16 +44,41 @@ type Config struct {
 	ObservabilityExporter observability.Config
 
 	Port             string `env:"PORT, default=8080"`
-	MaxKeysOnPublish int    `env:"MAX_KEYS_ON_PUBLISH, default=20"`
+	MaxKeysOnPublish uint   `env:"MAX_KEYS_ON_PUBLISH, default=20"`
 	// Provides compatibility w/ 1.5 release.
-	MaxSameStartIntervalKeys int           `env:"MAX_SAME_START_INTERVAL_KEYS, default=3"`
-	MaxIntervalAge           time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
-	TruncateWindow           time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+	MaxSameStartIntervalKeys     uint          `env:"MAX_SAME_START_INTERVAL_KEYS, default=3"`
+	MaxIntervalAge               time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
+	MaxMagnitudeSymptomOnsetDays uint          `env:"MAX_SYMPTOM_ONSET_DAYS, default=21"`
+	CreatedAtTruncateWindow      time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
 
 	// Flags for local development and testing. This will cause still valid keys
 	// to not be embargoed.
 	// Normally "still valid" keys can be accepted, but are embargoed.
-	DebugReleaseSameDayKeys bool `env:"DEBUG_RELEASE_SAME_DAY_KEYS"`
+	ReleaseSameDayKeys bool `env:"DEBUG_RELEASE_SAME_DAY_KEYS"`
+}
+
+func (c *Config) MaxExposureKeys() uint {
+	return c.MaxKeysOnPublish
+}
+
+func (c *Config) MaxSameDayKeys() uint {
+	return c.MaxSameStartIntervalKeys
+}
+
+func (c *Config) MaxIntervalStartAge() time.Duration {
+	return c.MaxIntervalAge
+}
+
+func (c *Config) TruncateWindow() time.Duration {
+	return c.CreatedAtTruncateWindow
+}
+
+func (c *Config) MaxSymptomOnsetDays() uint {
+	return c.MaxMagnitudeSymptomOnsetDays
+}
+
+func (c *Config) DebugReleaseSameDayKeys() bool {
+	return c.ReleaseSameDayKeys
 }
 
 func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
diff --git a/internal/publish/database/exposure.go b/internal/publish/database/exposure.go
index b014d3b..1dc3168 100644
--- a/internal/publish/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -17,6 +17,7 @@ package database
 
 import (
 	"context"
+	"database/sql"
 	"encoding/base64"
 	"fmt"
 	"strconv"
@@ -53,6 +54,7 @@ type IterateExposuresCriteria struct {
 	SinceTimestamp time.Time
 	UntilTimestamp time.Time
 	LastCursor     string
+	RevisedKeys    bool // If true, only revised keys that match will be selected.
 
 	// OnlyLocalProvenance indicates that only exposures with LocalProvenance=true will be returned.
 	OnlyLocalProvenance bool
@@ -112,7 +114,8 @@ func (db *PublishDB) IterateExposures(ctx context.Context, criteria IterateExpos
 			syncID     *int64
 		)
 		if err := rows.Scan(&encodedKey, &m.TransmissionRisk, &m.AppPackageName, &m.Regions, &m.IntervalNumber,
-			&m.IntervalCount, &m.CreatedAt, &m.LocalProvenance, &syncID); err != nil {
+			&m.IntervalCount, &m.CreatedAt, &m.LocalProvenance, &syncID, &m.HealthAuthorityID, &m.ReportType,
+			&m.DaysSinceSymptomOnset, &m.RevisedReportType, &m.RevisedAt, &m.RevisedDaysSinceSymptomOnset); err != nil {
 			return cursor(), err
 		}
 		var err error
@@ -139,7 +142,8 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 	q := `
 		SELECT
 			exposure_key, transmission_risk, LOWER(app_package_name), regions, interval_number, interval_count,
-			created_at, local_provenance, sync_id
+			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
 		FROM
 			Exposure
 		WHERE 1=1
@@ -155,6 +159,12 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 		q += fmt.Sprintf(" AND NOT (regions && $%d)", len(args)) // Operation "&&" means "array overlaps / intersects"
 	}
 
+	timeField := "created_at"
+	if criteria.RevisedKeys {
+		q += " AND revised_at IS NOT NULL"
+		timeField = "revised_at"
+	}
+
 	// It is important for StartTimestamp to be inclusive (as opposed to exclusive). When the exposure keys are
 	// published, they are truncated to a time boundary (e.g., time.Hour). Even though the exposure keys might arrive
 	// during a current open export batch window, the exposure keys are truncated to the start of that window,
@@ -162,12 +172,12 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 	// (in the case where the publish window and the export period align).
 	if !criteria.SinceTimestamp.IsZero() {
 		args = append(args, criteria.SinceTimestamp)
-		q += fmt.Sprintf(" AND created_at >= $%d", len(args))
+		q += fmt.Sprintf(" AND %s >= $%d", timeField, len(args))
 	}
 
 	if !criteria.UntilTimestamp.IsZero() {
 		args = append(args, criteria.UntilTimestamp)
-		q += fmt.Sprintf(" AND created_at < $%d", len(args))
+		q += fmt.Sprintf(" AND %s < $%d", timeField, len(args))
 	}
 
 	if criteria.OnlyLocalProvenance {
@@ -190,6 +200,73 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 	return q, args, nil
 }
 
+// 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()
+
+	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
+		FROM
+			Exposure
+		WHERE exposure_key = ANY($1)`
+	rows, err := conn.Query(ctx, query, b64keys)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	result := make(map[string]*model.Exposure)
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, fmt.Errorf("iterating rows: %w", err)
+		}
+
+		var encodedKey string
+		var syncID sql.NullInt64
+
+		var exposure model.Exposure
+		if err := rows.Scan(
+			&encodedKey, &exposure.TransmissionRisk, &exposure.AppPackageName,
+			&exposure.Regions, &exposure.IntervalNumber, &exposure.IntervalCount,
+			&exposure.CreatedAt, &exposure.LocalProvenance, &syncID,
+			&exposure.HealthAuthorityID, &exposure.ReportType, &exposure.DaysSinceSymptomOnset,
+			&exposure.RevisedReportType, &exposure.RevisedAt, &exposure.RevisedDaysSinceSymptomOnset,
+		); err != nil {
+			return nil, err
+		}
+
+		// Base64 decode the exposure key
+		exposure.ExposureKey, err = decodeExposureKey(encodedKey)
+		if err != nil {
+			return nil, err
+		}
+		// Optionally set all of the nullable columns.
+		if syncID.Valid {
+			exposure.FederationSyncID = syncID.Int64
+		}
+
+		result[exposure.ExposureKeyBase64()] = &exposure
+	}
+
+	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")
+}
+
 // 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 {
@@ -198,9 +275,9 @@ func (db *PublishDB) InsertExposures(ctx context.Context, exposures []*model.Exp
 			INSERT INTO
 				Exposure
 			    (exposure_key, transmission_risk, app_package_name, regions, interval_number, interval_count,
-			     created_at, local_provenance, sync_id)
+			     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)
+			  ($1, $2, LOWER($3), $4, $5, $6, $7, $8, $9, $10, $11, $12)
 			ON CONFLICT (exposure_key) DO NOTHING
 		`)
 		if err != nil {
@@ -212,8 +289,10 @@ func (db *PublishDB) InsertExposures(ctx context.Context, exposures []*model.Exp
 			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)
+			_, 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)
 			}
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index 3b7127f..49e927c 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -17,17 +17,73 @@ package model
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
+	"math"
 	"sort"
 	"strings"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/verification"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
 
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 )
 
+// Exposure represents the record as stored in the database
+type Exposure struct {
+	ExposureKey      []byte
+	TransmissionRisk int
+	AppPackageName   string
+	Regions          []string
+	IntervalNumber   int32
+	IntervalCount    int32
+	CreatedAt        time.Time
+	LocalProvenance  bool
+	FederationSyncID int64
+
+	// These fileds are nullable to maintain backwards compatibility with
+	// older versions that predate their existence.
+	HealthAuthorityID            *int64
+	ReportType                   string
+	DaysSinceSymptomOnset        *int32
+	RevisedReportType            *string
+	RevisedAt                    *time.Time
+	RevisedDaysSinceSymptomOnset *int
+}
+
+// HasDaysSinceSymptomOnset returns true if the this key has the days since
+// symptom onset field is et.
+func (e *Exposure) HasDaysSinceSymptomOnset() bool {
+	return e.DaysSinceSymptomOnset != nil
+}
+
+// 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
+}
+
+func (e *Exposure) HasHealthAuthorityID() bool {
+	return e.HealthAuthorityID != nil
+}
+
+func (e *Exposure) SetHealthAuthorityID(haID int64) {
+	if e.HealthAuthorityID == nil {
+		e.HealthAuthorityID = new(int64)
+	}
+	*e.HealthAuthorityID = haID
+}
+
+// ExposureKeyBase64 returns the ExposuerKey property base64 encoded.
+func (e *Exposure) ExposureKeyBase64() string {
+	return base64.StdEncoding.EncodeToString(e.ExposureKey)
+}
+
 // ApplyTransmissionRiskOverrides modifies the transmission risk values in the publish request
 // based on the provided TransmissionRiskVector.
 // In the live system, the TransmissionRiskVector values come from a trusted public health authority
@@ -60,30 +116,12 @@ func ApplyTransmissionRiskOverrides(p *verifyapi.Publish, overrides verifyapi.Tr
 		// Check to see if this key is in the current override.
 		// If the key was EVERY valid during the SinceRollingPeriod then the override applies.
 		if eKey.IntervalNumber+eKey.IntervalCount >= overrides[overrideIdx].SinceRollingInterval {
-			p.Keys[i].TransmissionRisk = overrides[overrideIdx].TranismissionRisk
+			p.Keys[i].TransmissionRisk = overrides[overrideIdx].TransmissionRisk
 			// don't advance overrideIdx, there might be additional keys in this override.
 		}
 	}
 }
 
-// Exposure represents the record as stored in the database
-// TODO(mikehelmick) - refactor this so that there is a public
-// Exposure struct that doesn't have public fields and an
-// internal struct that does. Separate out the database model
-// from direct access.
-// Mark records as writable/nowritable - is exposure key encrypted.
-type Exposure struct {
-	ExposureKey      []byte
-	TransmissionRisk int
-	AppPackageName   string
-	Regions          []string
-	IntervalNumber   int32
-	IntervalCount    int32
-	CreatedAt        time.Time
-	LocalProvenance  bool
-	FederationSyncID int64
-}
-
 // IntervalNumber calculates the exposure notification system interval
 // number based on the input time.
 func IntervalNumber(t time.Time) int32 {
@@ -101,31 +139,64 @@ func TimeForIntervalNumber(interval int32) time.Time {
 	return time.Unix(int64(verifyapi.IntervalLength.Seconds())*int64(interval), 0)
 }
 
+// DaysFromSymptomOnset calculates the number of days between two start intervals.
+// Partial days are rounded up/down to the closest day.
+// If the checkInterval is before the onsetInterval, number of days will be negative.
+func DaysFromSymptomOnset(onsetInterval int32, checkInterval int32) int32 {
+	distance := checkInterval - onsetInterval
+	days := distance / verifyapi.MaxIntervalCount
+	// if the days don't divide evenly, round (up or down) to the closest even day.
+	if rem := distance % verifyapi.MaxIntervalCount; rem != 0 {
+		// remainder of negative number is negative in go. So if the ABS is more than
+		// half a day, adjust the day count.
+		if math.Abs(float64(rem)) > verifyapi.MaxIntervalCount/2 {
+			// Account for the fact that if day is 0 and rem is > half a day, sign of rem matters.
+			if days < 0 || rem < 0 {
+				days--
+			} else {
+				days++
+			}
+		}
+	}
+	return days
+}
+
+type TransformerConfig interface {
+	MaxExposureKeys() uint
+	MaxSameDayKeys() uint
+	MaxIntervalStartAge() time.Duration
+	TruncateWindow() time.Duration
+	MaxSymptomOnsetDays() uint
+	DebugReleaseSameDayKeys() bool
+}
+
 // Transformer represents a configured Publish -> Exposure[] transformer.
 type Transformer struct {
 	maxExposureKeys     int           // Overall maximum number of keys.
 	maxSameDayKeys      int           // Number of keys that are allowed to have the same start interval.
 	maxIntervalStartAge time.Duration // How many intervals old does this server accept?
 	truncateWindow      time.Duration
-	debugReleaseSameDay bool // If true, still valid keys are not embargoed.
+	maxSymptomOnsetDays float64 // to avoid casting in comparisons
+	debugReleaseSameDay bool    // If true, still valid keys are not embargoed.
 }
 
 // NewTransformer creates a transformer for turning publish API requests into
 // records for insertion into the database. On the call to TransformPublish
 // all data is validated according to the transformer that is used.
-func NewTransformer(maxExposureKeys int, maxSameDayKeys int, maxIntervalStartAge time.Duration, truncateWindow time.Duration, releaseSameDayKeys bool) (*Transformer, error) {
-	if maxExposureKeys <= 0 {
-		return nil, fmt.Errorf("maxExposureKeys must be > 0, got %v", maxExposureKeys)
+func NewTransformer(config TransformerConfig) (*Transformer, error) {
+	if config.MaxExposureKeys() <= 0 {
+		return nil, fmt.Errorf("maxExposureKeys must be > 0, got %v", config.MaxExposureKeys())
 	}
-	if maxSameDayKeys < 1 {
-		return nil, fmt.Errorf("maxSameDayKeys must be >= 1, got %v", maxSameDayKeys)
+	if config.MaxSameDayKeys() < 1 {
+		return nil, fmt.Errorf("maxSameDayKeys must be >= 1, got %v", config.MaxSameDayKeys())
 	}
 	return &Transformer{
-		maxExposureKeys:     maxExposureKeys,
-		maxSameDayKeys:      maxSameDayKeys,
-		maxIntervalStartAge: maxIntervalStartAge,
-		truncateWindow:      truncateWindow,
-		debugReleaseSameDay: releaseSameDayKeys,
+		maxExposureKeys:     int(config.MaxExposureKeys()),
+		maxSameDayKeys:      int(config.MaxSameDayKeys()),
+		maxIntervalStartAge: config.MaxIntervalStartAge(),
+		truncateWindow:      config.TruncateWindow(),
+		maxSymptomOnsetDays: float64(config.MaxSymptomOnsetDays()),
+		debugReleaseSameDay: config.DebugReleaseSameDayKeys(),
 	}, nil
 }
 
@@ -193,13 +264,37 @@ 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))
+
+	return nil, fmt.Errorf("KEY REVISION NOT YET IMPLEMENTED")
+}
+
+func ReportTypeTransmissionRisk(reportType string, providedTR int) int {
+	// If the client provided a transmission risk, we'll use that.
+	if providedTR != 0 {
+		return providedTR
+	}
+	// Otherwise this value needs to be backfilled for v1.0 clients.
+	switch reportType {
+	case verifyapi.ReportTypeConfirmed:
+		return verifyapi.TransmissionRiskConfirmedStandard
+	case verifyapi.ReportTypeClinical:
+		return verifyapi.TransmissionRiskClinical
+	case verifyapi.ReportTypeNegative:
+		return verifyapi.TransmissionRiskNegative
+	}
+	return verifyapi.TransmissionRiskUnknown
+}
+
 // TransformPublish converts incoming key data to a list of exposure entities.
 // The data in the request is validated during the transform, including:
 //
 // * 0 exposure Keys in the requests
 // * > Transformer.maxExposureKeys in the request
 //
-func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Publish, batchTime time.Time) ([]*Exposure, error) {
+func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Publish, claims *verification.VerifiedClaims, batchTime time.Time) ([]*Exposure, error) {
 	logger := logging.FromContext(ctx)
 	if t.debugReleaseSameDay {
 		logger.Errorf("DEBUG SERVER - Current day keys are not being embargoed.")
@@ -217,6 +312,15 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		return nil, fmt.Errorf(msg)
 	}
 
+	if claims != nil {
+		ApplyTransmissionRiskOverrides(inData, claims.TransmissionRisks)
+	}
+
+	onsetInterval := inData.SymptomOnsetInterval
+	if claims != nil && claims.SymptomOnsetInterval > 0 {
+		onsetInterval = int32(claims.SymptomOnsetInterval)
+	}
+
 	defaultCreatedAt := TruncateWindow(batchTime, t.truncateWindow)
 	entities := make([]*Exposure, 0, len(inData.Keys))
 
@@ -247,6 +351,23 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 			logger.Debugf("individual key transform failed: %v", err)
 			return nil, fmt.Errorf("invalid publish data: %v", err)
 		}
+		// If there are verified claims, apply to this key.
+		if claims != nil {
+			if claims.ReportType != "" {
+				exposure.ReportType = claims.ReportType
+			}
+			exposure.TransmissionRisk = ReportTypeTransmissionRisk(claims.ReportType, exposure.TransmissionRisk)
+			if claims.HealthAuthorityID > 0 {
+				exposure.SetHealthAuthorityID(claims.HealthAuthorityID)
+			}
+		}
+		// Set days since onset, either from the API or from the verified claims (see above).
+		if onsetInterval > 0 {
+			daysSince := DaysFromSymptomOnset(onsetInterval, exposure.IntervalNumber)
+			if math.Abs(float64(daysSince)) < t.maxSymptomOnsetDays {
+				exposure.SetDaysSinceSymptomOnset(daysSince)
+			}
+		}
 		entities = append(entities, exposure)
 	}
 
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 970981a..0bfacc9 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -46,7 +46,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("missing AuthorizedApp provider in server environment")
 	}
 
-	transformer, err := model.NewTransformer(config.MaxKeysOnPublish, config.MaxSameStartIntervalKeys, config.MaxIntervalAge, config.TruncateWindow, config.DebugReleaseSameDayKeys)
+	transformer, err := model.NewTransformer(config)
 	if err != nil {
 		return nil, fmt.Errorf("model.NewTransformer: %w", err)
 	}
@@ -54,7 +54,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 	logger.Infof("max same start interval keys: %v", config.MaxSameStartIntervalKeys)
 	logger.Infof("max interval start age: %v", config.MaxIntervalAge)
 	logger.Infof("truncate window: %v", config.TruncateWindow)
-	if config.DebugReleaseSameDayKeys {
+	if config.DebugReleaseSameDayKeys() {
 		logger.Warnf("SERVER IS IN DEBUG MODE. KEYS MAY BE RELEASED EARLY.")
 	}
 
@@ -148,7 +148,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	}
 
 	// Perform health authority certificat verification.
-	overrides, err := h.verifier.VerifyDiagnosisCertificate(ctx, appConfig, &data)
+	verifiedClaims, err := h.verifier.VerifyDiagnosisCertificate(ctx, appConfig, &data)
 	if err != nil {
 		if appConfig.BypassHealthAuthorityVerification {
 			logger.Warnf("bypassing health authority certificate verification for app: %v", appConfig.AppPackageName)
@@ -160,13 +160,8 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		}
 	}
 
-	// Apply overrides
-	if len(overrides) > 0 {
-		model.ApplyTransmissionRiskOverrides(&data, overrides)
-	}
-
 	batchTime := time.Now()
-	exposures, err := h.transformer.TransformPublish(ctx, &data, batchTime)
+	exposures, err := h.transformer.TransformPublish(ctx, &data, verifiedClaims, batchTime)
 	if err != nil {
 		message := fmt.Sprintf("unable to read request data: %v", err)
 		logger.Errorf(message)
@@ -174,9 +169,35 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		return response{status: http.StatusBadRequest, message: message, metric: "publish-transform-fail", count: 1}
 	}
 
-	err = h.database.InsertExposures(ctx, exposures)
+	// 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)
 	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}
 	}
diff --git a/internal/util/generate.go b/internal/util/generate.go
index fc4a992..5be571a 100644
--- a/internal/util/generate.go
+++ b/internal/util/generate.go
@@ -59,6 +59,22 @@ func RandomIntWithMin(min, max int) (int, error) {
 	return int(n.Int64()) + min, nil
 }
 
+func RandomReportType() (string, error) {
+	n, err := RandomInt(3)
+	if err != nil {
+		return "", err
+	}
+	switch n {
+	case 0:
+		return v1alpha1.ReportTypeConfirmed, nil
+	case 1:
+		return v1alpha1.ReportTypeClinical, nil
+	case 2:
+		return v1alpha1.ReportTypeNegative, nil
+	}
+	return v1alpha1.ReportTypeConfirmed, nil
+}
+
 // RandomTransmissionRisk produces a random transmission risk score.
 func RandomTransmissionRisk() (int, error) {
 	n, err := RandomInt(v1alpha1.MaxTransmissionRisk)
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
index 43843ba..7dea8f5 100644
--- a/internal/verification/phaverify.go
+++ b/internal/verification/phaverify.go
@@ -48,10 +48,19 @@ func New(db *database.HealthAuthorityDB, config *Config) (*Verifier, error) {
 	return &Verifier{db, cache}, nil
 }
 
+// VerifiedClaims represents the relevant claims extracted from a verified
+// certificate that may need to be applied.
+type VerifiedClaims struct {
+	HealthAuthorityID    int64
+	ReportType           string // blank indicates no report type was present.
+	SymptomOnsetInterval uint32 // 0 indicates no symptom onset interval present. This should be checked for "reasonable" value before application.
+	TransmissionRisks    verifyapi.TransmissionRiskVector
+}
+
 // VerifyDiagnosisCertificate accepts a publish request (from which is extracts the JWT),
 // fully verifies the JWT and signture against what the passed in authorrized app is allowed
 // to use. Returns any transmission risk overrides if they are present.
-func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamodel.AuthorizedApp, publish *verifyapi.Publish) (verifyapi.TransmissionRiskVector, error) {
+func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamodel.AuthorizedApp, publish *verifyapi.Publish) (*VerifiedClaims, error) {
 	// These get assigned during the ParseWithClaims closure.
 	var healthAuthorityID int64
 	var claims *verifyapi.VerificationClaims
@@ -134,6 +143,11 @@ func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamo
 		return nil, fmt.Errorf("HMAC mismatch, publish request does not match disgnosis verification certificate")
 	}
 
-	// Everything looks good. Return the transmission override vector.
-	return claims.TransmissionRisks, nil
+	// Everything looks good. Return the relevant verified claims.
+	return &VerifiedClaims{
+		HealthAuthorityID:    healthAuthorityID,
+		ReportType:           claims.ReportType,
+		SymptomOnsetInterval: claims.SymptomOnsetInterval,
+		TransmissionRisks:    claims.TransmissionRisks,
+	}, nil
 }
diff --git a/migrations/000031_Addv1dot5Columns.down.sql b/migrations/000031_Addv1dot5Columns.down.sql
new file mode 100644
index 0000000..2dc860e
--- /dev/null
+++ b/migrations/000031_Addv1dot5Columns.down.sql
@@ -0,0 +1,27 @@
+-- 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;
+
+DROP INDEX exposure_revised_at_idx;
+
+ALTER TABLE exposure
+  DROP COLUMN health_authority_id,
+	DROP COLUMN report_type,
+	DROP COLUMN days_since_symptom_onset,
+	DROP COLUMN revised_report_type,
+	DROP COLUMN revised_at,
+  DROP COLUMN revised_days_since_symptom_onset;
+
+END;
diff --git a/migrations/000031_Addv1dot5Columns.up.sql b/migrations/000031_Addv1dot5Columns.up.sql
new file mode 100644
index 0000000..5735718
--- /dev/null
+++ b/migrations/000031_Addv1dot5Columns.up.sql
@@ -0,0 +1,28 @@
+-- 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;
+
+-- health_authority_id is nullable to preserve backwards compatibility.
+ALTER TABLE exposure
+	ADD COLUMN health_authority_id INT REFERENCES HealthAuthority(id),
+	ADD COLUMN report_type VARCHAR(20) DEFAULT '' NOT NULL,
+	ADD COLUMN days_since_symptom_onset INT,
+	ADD COLUMN revised_report_type VARCHAR(20),
+	ADD COLUMN revised_at TIMESTAMPTZ,
+	ADD COLUMN revised_days_since_symptom_onset INT;
+
+CREATE INDEX exposure_revised_at_idx ON exposure USING BRIN(revised_at);
+
+END;
diff --git a/pkg/api/v1alpha1/exposure_types.go b/pkg/api/v1alpha1/exposure_types.go
index 1f0fab3..85703f0 100644
--- a/pkg/api/v1alpha1/exposure_types.go
+++ b/pkg/api/v1alpha1/exposure_types.go
@@ -44,24 +44,32 @@ const (
 // AppPackageName: The identifier for the mobile application.
 //  - Android: The App Package AppPackageName
 //  - iOS: The BundleID
-// TransmissionRisk: An integer from 0-8 (inclusive) that represents
-//  the transmission risk for this publish.
-// Verification: The attestation payload for this request. (iOS or Android specific)
-//   Base64 encoded.
-// VerificationAuthorityName: a string that should be verified against the code provider.
-//  Note: This project doesn't directly include a diagnosis code verification System
-//        but does provide the ability to configure one in `serverevn.ServerEnv`
+// VerifcationPayload: The Verification Certificate from a verification server.
+// HMACKey: the device generated secret that is used to recalcualte the HMAC value
+//  that is present in the verification payload.
+//
+// SymptomOnsetInterval: An interval number that aligns with the symptom onset date.
+//  - Uses the same interval system as TEK timing.
+//  - Will be rounded down to the start of the UTC day provided.
+//  - Will be used to calculate the days +/- symptom onset for provided keys.
+//  - MUST be no more than 14 days ago.
+//  - Does not have to be within range of any of the provided keys (i.e. future
+//    key uploads)
+//
+// Padding: random base64 encoded data to obscure the request size.
 //
 // The following fields are deprecated, but accepted for backwards-compatibility:
 // DeviceVerificationPayload: (attestation)
 // Platform: "ios" or "android"
 type Publish struct {
-	Keys                []ExposureKey `json:"temporaryExposureKeys"`
-	Regions             []string      `json:"regions"`
-	AppPackageName      string        `json:"appPackageName"`
-	VerificationPayload string        `json:"verificationPayload"`
-	HMACKey             string        `json:"hmackey"`
-	Padding             string        `json:"padding"`
+	Keys                 []ExposureKey `json:"temporaryExposureKeys"`
+	Regions              []string      `json:"regions"`
+	AppPackageName       string        `json:"appPackageName"`
+	VerificationPayload  string        `json:"verificationPayload"`
+	HMACKey              string        `json:"hmackey"`
+	SymptomOnsetInterval int32         `json:"symptomOnsetInterval"`
+
+	Padding string `json:"padding"`
 
 	Platform                  string `json:"platform"`                  // DEPRECATED
 	DeviceVerificationPayload string `json:"deviceVerificationPayload"` // DEPRECATED
@@ -80,11 +88,19 @@ type Publish struct {
 // IntervalCount must >= `minIntervalCount` and <= `maxIntervalCount`
 //   1 - 144 inclusive.
 // transmissionRisk must be >= 0 and <= 8.
+//   Transmission risk is depercated, but should still be populated for compatibility
+//   with olrder clients. If it is omitted, and there is a valid report type,
+//   then transmissionRisk will be set to 0.
+//   IF there is a report type from the verification certificate AND tranismission risk
+//    is not set, then a report type of
+//     CONFIRMED will lead to TR 2
+//     LIKELY will lead to TR 4
+//     NEGATIVE will lead to TR 6
 type ExposureKey struct {
 	Key              string `json:"key"`
 	IntervalNumber   int32  `json:"rollingStartNumber"`
 	IntervalCount    int32  `json:"rollingPeriod"`
-	TransmissionRisk int    `json:"transmissionRisk"`
+	TransmissionRisk int    `json:"transmissionRisk"` // DEPRECATED
 }
 
 // ExposureKeys represents a set of ExposureKey objects as input to
diff --git a/pkg/api/v1alpha1/verification_types.go b/pkg/api/v1alpha1/verification_types.go
index 0dd1b3a..d30eff6 100644
--- a/pkg/api/v1alpha1/verification_types.go
+++ b/pkg/api/v1alpha1/verification_types.go
@@ -45,6 +45,11 @@ const (
 	ReportTypeClinical = "likely"
 	// ReportTypeNegative is allowed by the verification flow. These keys are not saved in the system.
 	ReportTypeNegative = "negative"
+
+	TransmissionRiskUnknown           = 0
+	TransmissionRiskConfirmedStandard = 2
+	TransmissionRiskClinical          = 4
+	TransmissionRiskNegative          = 6
 )
 
 // TransmissionRiskVector is an additional set of claims that can be
@@ -52,12 +57,12 @@ const (
 // from a trusted public health authority.
 type TransmissionRiskVector []TransmissionRiskOverride
 
-// Compile time check that TranismissionRiskVector implements the sort interface.
+// Compile time check that TransmissionRiskVector implements the sort interface.
 var _ sort.Interface = TransmissionRiskVector{}
 
 // TransmissionRiskOverride is an indvidual transmission risk override.
 type TransmissionRiskOverride struct {
-	TranismissionRisk    int   `json:"tr"`
+	TransmissionRisk     int   `json:"tr"`
 	SinceRollingInterval int32 `json:"sinceRollingInterval"`
 }
 


Test patch:
diff --git a/internal/publish/database/exposure_test.go b/internal/publish/database/exposure_test.go
index ff84689..41893c3 100644
--- a/internal/publish/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -18,6 +18,8 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"sort"
+	"strings"
 	"testing"
 	"time"
 
@@ -25,8 +27,71 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
+var (
+	approxTime = cmp.Options{cmpopts.EquateApproxTime(time.Second)}
+)
+
+func TestReadExposures(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	testPublishDB := New(testDB)
+	ctx := context.Background()
+
+	// Insert some Exposures.
+	createdAt := time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC).Truncate(time.Microsecond)
+	exposures := []*model.Exposure{
+		{
+			ExposureKey:     []byte("ABC123"),
+			Regions:         []string{"US"},
+			IntervalNumber:  100,
+			IntervalCount:   144,
+			CreatedAt:       createdAt,
+			LocalProvenance: true,
+		},
+		{
+			ExposureKey:     []byte("DEF456"),
+			Regions:         []string{"US"},
+			IntervalNumber:  244,
+			IntervalCount:   144,
+			CreatedAt:       createdAt,
+			LocalProvenance: true,
+		},
+	}
+	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
+		t.Fatal(err)
+	}
+
+	keys := make([]string, 0, len(exposures))
+	for _, e := range exposures {
+		keys = append(keys, e.ExposureKeyBase64())
+	}
+
+	readBack, err := testPublishDB.ReadExposures(ctx, keys)
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := make([]*model.Exposure, 0, len(exposures))
+	for _, v := range readBack {
+		got = append(got, v)
+	}
+
+	sorter := cmp.Transformer("Sort", func(in []*model.Exposure) []*model.Exposure {
+		out := append([]*model.Exposure(nil), in...) // Copy input to avoid mutating it
+		sort.Slice(out, func(i int, j int) bool {
+			return strings.Compare(out[i].ExposureKeyBase64(), out[j].ExposureKeyBase64()) <= 0
+		})
+		return out
+	})
+
+	if diff := cmp.Diff(exposures, got, approxTime, sorter); diff != "" {
+		t.Errorf("ReadExposures mismatch (-want, +got):\n%s", diff)
+	}
+}
+
 func TestExposures(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index b3e6c6d..3c98529 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -24,11 +24,50 @@ import (
 	"testing"
 	"time"
 
+	"github.com/google/exposure-notifications-server/internal/verification"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
+
 	"github.com/google/go-cmp/cmp"
 )
 
+const (
+	maxSymptomOnsetDays = 21
+)
+
+type testConfig struct {
+	maxExposureKeys     uint
+	maxSameDayKeys      uint
+	maxIntervalStartAge time.Duration
+	truncateWindow      time.Duration
+	maxSymptomOnsetDays uint
+	debugReleaseSameDay bool
+}
+
+func (c *testConfig) MaxExposureKeys() uint {
+	return c.maxExposureKeys
+}
+
+func (c *testConfig) MaxSameDayKeys() uint {
+	return c.maxSameDayKeys
+}
+
+func (c *testConfig) MaxIntervalStartAge() time.Duration {
+	return c.maxIntervalStartAge
+}
+
+func (c *testConfig) TruncateWindow() time.Duration {
+	return c.truncateWindow
+}
+
+func (c *testConfig) MaxSymptomOnsetDays() uint {
+	return c.maxSymptomOnsetDays
+}
+
+func (c *testConfig) DebugReleaseSameDayKeys() bool {
+	return c.debugReleaseSameDay
+}
+
 func TestIntervalNumber(t *testing.T) {
 	// Since time to interval is lossy, truncate down to the beginnging of a window.
 	now := time.Now().Truncate(verifyapi.IntervalLength)
@@ -44,11 +83,10 @@ func TestIntervalNumber(t *testing.T) {
 func TestInvalidNew(t *testing.T) {
 	errMsg := "maxExposureKeys must be > 0"
 	cases := []struct {
-		maxKeys        int
-		maxSameDayKeys int
+		maxKeys        uint
+		maxSameDayKeys uint
 		message        string
 	}{
-		{-1, 3, "maxExposureKeys must be > 0"},
 		{0, 3, "maxExposureKeys must be > 0"},
 		{1, 3, ""},
 		{5, 1, ""},
@@ -56,7 +94,7 @@ func TestInvalidNew(t *testing.T) {
 	}
 
 	for i, c := range cases {
-		_, err := NewTransformer(c.maxKeys, c.maxSameDayKeys, time.Hour, time.Hour, false)
+		_, err := NewTransformer(&testConfig{c.maxKeys, c.maxSameDayKeys, time.Hour, time.Hour, maxSymptomOnsetDays, false})
 		if err != nil && errMsg == "" {
 			t.Errorf("%v unexpected error: %v", i, err)
 		} else if err != nil && !strings.Contains(err.Error(), c.message) {
@@ -67,7 +105,7 @@ func TestInvalidNew(t *testing.T) {
 
 func TestInvalidBase64(t *testing.T) {
 	ctx := context.Background()
-	transformer, err := NewTransformer(1, 1, time.Hour*24, time.Hour, false)
+	transformer, err := NewTransformer(&testConfig{1, 1, time.Hour * 24, time.Hour, maxSymptomOnsetDays, false})
 	if err != nil {
 		t.Fatalf("error creating transformer: %v", err)
 	}
@@ -83,7 +121,7 @@ func TestInvalidBase64(t *testing.T) {
 	}
 	batchTime := time.Date(2020, 3, 1, 10, 43, 1, 0, time.UTC)
 
-	_, err = transformer.TransformPublish(ctx, source, batchTime)
+	_, err = transformer.TransformPublish(ctx, source, nil, batchTime)
 	expErr := `invalid publish data: illegal base64 data at input byte 4`
 	if err == nil || err.Error() != expErr {
 		t.Errorf("expected error '%v', got: %v", expErr, err)
@@ -253,12 +291,12 @@ func TestPublishValidation(t *testing.T) {
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 			ctx := context.Background()
-			tf, err := NewTransformer(2, 1, maxAge, time.Hour, c.sameDay)
+			tf, err := NewTransformer(&testConfig{2, 1, maxAge, time.Hour, maxSymptomOnsetDays, c.sameDay})
 			if err != nil {
 				t.Fatalf("unepected error: %v", err)
 			}
 
-			_, err = tf.TransformPublish(ctx, c.p, captureStartTime)
+			_, err = tf.TransformPublish(ctx, c.p, nil, captureStartTime)
 			if err == nil {
 				if c.m != "" {
 					t.Errorf("want error '%v', got nil", c.m)
@@ -286,14 +324,6 @@ func encodeKey(key []byte) string {
 	return base64.StdEncoding.EncodeToString(key)
 }
 
-func decodeKey(b64key string, t *testing.T) []byte {
-	k, err := base64util.DecodeString(b64key)
-	if err != nil {
-		t.Fatalf("unable to decode key: %v", err)
-	}
-	return k
-}
-
 func TestStillValidKey(t *testing.T) {
 	now := time.Now()
 	batchWindow := TruncateWindow(now, time.Minute)
@@ -340,13 +370,13 @@ func TestStillValidKey(t *testing.T) {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			allowedAge := 2 * 24 * time.Hour
-			transformer, err := NewTransformer(10, 1, allowedAge, time.Minute, tc.releaseSameDayKeys)
+			transformer, err := NewTransformer(&testConfig{10, 1, allowedAge, time.Minute, maxSymptomOnsetDays, tc.releaseSameDayKeys})
 			if err != nil {
 				t.Fatal(err)
 			}
 
 			ctx := context.Background()
-			tf, err := transformer.TransformPublish(ctx, &tc.source, now)
+			tf, err := transformer.TransformPublish(ctx, &tc.source, nil, now)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -362,98 +392,339 @@ func TestStillValidKey(t *testing.T) {
 	}
 }
 
+func TestReportTypeToTransmissionRisk(t *testing.T) {
+	cases := []struct {
+		name   string
+		report string
+		inTR   int
+		wantTR int
+	}{
+		{"provided_tr_with_report", verifyapi.ReportTypeClinical, 8, 8},
+		{"provided_tr_no_report", "", 7, 7},
+		{"positive_report_backfill", verifyapi.ReportTypeConfirmed, 0, verifyapi.TransmissionRiskConfirmedStandard},
+		{"clinical_report_backfill", verifyapi.ReportTypeClinical, 0, verifyapi.TransmissionRiskClinical},
+		{"negative_report_backfill", verifyapi.ReportTypeNegative, 0, verifyapi.TransmissionRiskNegative},
+		{"no_tr_no_report", "", 0, verifyapi.TransmissionRiskUnknown},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := ReportTypeTransmissionRisk(tc.report, tc.inTR)
+			if tc.wantTR != got {
+				t.Fatalf("wrong output transmission risk, want: %v got %v", tc.wantTR, got)
+			}
+		})
+	}
+}
+
+func int32Ptr(v int32) *int32 { return &v }
+
+func int64Ptr(v int64) *int64 { return &v }
+
 func TestTransform(t *testing.T) {
 	captureStartTime := time.Date(2020, 2, 29, 11, 15, 1, 0, time.UTC)
 	intervalNumber := IntervalNumber(captureStartTime)
 
-	source := &verifyapi.Publish{
-		Keys: []verifyapi.ExposureKey{
-			{
-				Key:              encodeKey(generateKey(t)),
-				IntervalNumber:   intervalNumber,
-				IntervalCount:    verifyapi.MaxIntervalCount,
-				TransmissionRisk: 1,
-			},
-			{
-				Key:              encodeKey(generateKey(t)),
-				IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
-				IntervalCount:    verifyapi.MaxIntervalCount,
-				TransmissionRisk: 2,
-			},
-			{
-				Key:              encodeKey(generateKey(t)),
-				IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
-				IntervalCount:    verifyapi.MaxIntervalCount, // Invalid, should get rounded down
-				TransmissionRisk: 3,
-			},
-			{
-				Key:              encodeKey(generateKey(t)),
-				IntervalNumber:   intervalNumber + 3*verifyapi.MaxIntervalCount,
-				IntervalCount:    42,
-				TransmissionRisk: 4,
-			},
-		},
-		Regions:        []string{"us", "cA", "Mx"}, // will be upcased
-		AppPackageName: "com.google",
-		// Verification doesn't matter for transforming.
+	testKeys := make([][]byte, 15)
+	for i := 0; i < len(testKeys); i++ {
+		testKeys[i] = generateKey(t)
 	}
 
-	want := []*Exposure{
+	const appPackage = "com.google.app"
+	wantRegions := []string{"US", "CA", "MX"}
+	batchTime := captureStartTime.Add(time.Hour * 24 * 7)
+	batchTimeRounded := TruncateWindow(batchTime, time.Hour)
+
+	cases := []struct {
+		Name    string
+		Publish *verifyapi.Publish
+		Claims  *verification.VerifiedClaims
+		Want    []*Exposure
+	}{
 		{
-			ExposureKey:      decodeKey(source.Keys[0].Key, t),
-			IntervalNumber:   intervalNumber,
-			IntervalCount:    verifyapi.MaxIntervalCount,
-			TransmissionRisk: 1,
+			Name: "basic_v1_publish",
+			Publish: &verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:              encodeKey(testKeys[0]),
+						IntervalNumber:   intervalNumber,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 1,
+					},
+					{
+						Key:              encodeKey(testKeys[1]),
+						IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 2,
+					},
+					{
+						Key:              encodeKey(testKeys[2]),
+						IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
+						IntervalCount:    verifyapi.MaxIntervalCount, // Invalid, should get rounded down
+						TransmissionRisk: 3,
+					},
+					{
+						Key:              encodeKey(testKeys[3]),
+						IntervalNumber:   intervalNumber + 3*verifyapi.MaxIntervalCount,
+						IntervalCount:    42,
+						TransmissionRisk: 4,
+					},
+				},
+				Regions:        []string{"us", "cA", "Mx"}, // will be upcased
+				AppPackageName: appPackage,
+			},
+			Claims: nil,
+			Want: []*Exposure{
+				{
+					ExposureKey:      testKeys[0],
+					IntervalNumber:   intervalNumber,
+					IntervalCount:    verifyapi.MaxIntervalCount,
+					TransmissionRisk: 1,
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+				{
+					ExposureKey:      testKeys[1],
+					IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
+					IntervalCount:    verifyapi.MaxIntervalCount,
+					TransmissionRisk: 2,
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+				{
+					ExposureKey:      testKeys[2],
+					IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
+					IntervalCount:    verifyapi.MaxIntervalCount,
+					TransmissionRisk: 3,
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+				{
+					ExposureKey:      testKeys[3],
+					IntervalNumber:   intervalNumber + 3*verifyapi.MaxIntervalCount,
+					IntervalCount:    42,
+					TransmissionRisk: 4,
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+			},
 		},
 		{
-			ExposureKey:      decodeKey(source.Keys[1].Key, t),
-			IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
-			IntervalCount:    verifyapi.MaxIntervalCount,
-			TransmissionRisk: 2,
+			Name: "transmission_risk_overrides",
+			Publish: &verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:              encodeKey(testKeys[0]),
+						IntervalNumber:   intervalNumber,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 7,
+					},
+					{
+						Key:              encodeKey(testKeys[1]),
+						IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 7,
+					},
+				},
+				Regions:        wantRegions,
+				AppPackageName: appPackage,
+			},
+			Claims: &verification.VerifiedClaims{
+				TransmissionRisks: []verifyapi.TransmissionRiskOverride{
+					{TransmissionRisk: 0, SinceRollingInterval: 0},
+					{TransmissionRisk: 3, SinceRollingInterval: intervalNumber + verifyapi.MaxIntervalCount},
+				},
+			},
+			Want: []*Exposure{
+				{
+					ExposureKey:      testKeys[0],
+					IntervalNumber:   intervalNumber,
+					IntervalCount:    verifyapi.MaxIntervalCount,
+					TransmissionRisk: 0, // default override
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+				{
+					ExposureKey:      testKeys[1],
+					IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
+					IntervalCount:    verifyapi.MaxIntervalCount,
+					TransmissionRisk: 3, // mateched specific override in input case.
+					AppPackageName:   appPackage,
+					Regions:          wantRegions,
+					CreatedAt:        batchTimeRounded,
+					LocalProvenance:  true,
+				},
+			},
 		},
 		{
-			ExposureKey:      decodeKey(source.Keys[2].Key, t),
-			IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
-			IntervalCount:    verifyapi.MaxIntervalCount,
-			TransmissionRisk: 3,
+			Name: "claims_with_report_type_no_backfill",
+			Publish: &verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:              encodeKey(testKeys[3]),
+						IntervalNumber:   intervalNumber,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 7,
+					},
+					{
+						Key:              encodeKey(testKeys[4]),
+						IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 7,
+					},
+					{
+						Key:              encodeKey(testKeys[5]),
+						IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 7,
+					},
+				},
+				Regions:        wantRegions,
+				AppPackageName: appPackage,
+			},
+			Claims: &verification.VerifiedClaims{
+				ReportType:           verifyapi.ReportTypeConfirmed,
+				SymptomOnsetInterval: uint32(intervalNumber + verifyapi.MaxIntervalCount),
+			},
+			Want: []*Exposure{
+				{
+					ExposureKey:           testKeys[3],
+					IntervalNumber:        intervalNumber,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      7, // was provided, shouldn't be changed
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeConfirmed,
+					DaysSinceSymptomOnset: int32Ptr(-1),
+				},
+				{
+					ExposureKey:           testKeys[4],
+					IntervalNumber:        intervalNumber + verifyapi.MaxIntervalCount,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      7, // was provided, shouldn't be changed
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeConfirmed,
+					DaysSinceSymptomOnset: int32Ptr(0),
+				},
+				{
+					ExposureKey:           testKeys[5],
+					IntervalNumber:        intervalNumber + 2*verifyapi.MaxIntervalCount,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      7, // was provided, shouldn't be changed
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeConfirmed,
+					DaysSinceSymptomOnset: int32Ptr(1),
+				},
+			},
 		},
 		{
-			ExposureKey:      decodeKey(source.Keys[3].Key, t),
-			IntervalNumber:   intervalNumber + 3*verifyapi.MaxIntervalCount,
-			IntervalCount:    42,
-			TransmissionRisk: 4,
+			Name: "claims_with_report_type_with_backfill",
+			Publish: &verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:            encodeKey(testKeys[3]),
+						IntervalNumber: intervalNumber,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+					{
+						Key:            encodeKey(testKeys[4]),
+						IntervalNumber: intervalNumber + verifyapi.MaxIntervalCount,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+					{
+						Key:            encodeKey(testKeys[5]),
+						IntervalNumber: intervalNumber + 2*verifyapi.MaxIntervalCount,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+				},
+				Regions:        wantRegions,
+				AppPackageName: appPackage,
+			},
+			Claims: &verification.VerifiedClaims{
+				HealthAuthorityID:    27,
+				ReportType:           verifyapi.ReportTypeClinical,
+				SymptomOnsetInterval: uint32(intervalNumber + 2*verifyapi.MaxIntervalCount),
+			},
+			Want: []*Exposure{
+				{
+					ExposureKey:           testKeys[3],
+					IntervalNumber:        intervalNumber,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeClinical,
+					DaysSinceSymptomOnset: int32Ptr(-2),
+					HealthAuthorityID:     int64Ptr(27),
+				},
+				{
+					ExposureKey:           testKeys[4],
+					IntervalNumber:        intervalNumber + verifyapi.MaxIntervalCount,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeClinical,
+					DaysSinceSymptomOnset: int32Ptr(-1),
+					HealthAuthorityID:     int64Ptr(27),
+				},
+				{
+					ExposureKey:           testKeys[5],
+					IntervalNumber:        intervalNumber + 2*verifyapi.MaxIntervalCount,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeClinical,
+					DaysSinceSymptomOnset: int32Ptr(0),
+					HealthAuthorityID:     int64Ptr(27),
+				},
+			},
 		},
 	}
-	batchTime := captureStartTime.Add(time.Hour * 24 * 7)
-	batchTimeRounded := TruncateWindow(batchTime, time.Hour)
-	for i, v := range want {
-		want[i] = &Exposure{
-			ExposureKey:      v.ExposureKey,
-			TransmissionRisk: i + 1,
-			AppPackageName:   "com.google",
-			Regions:          []string{"US", "CA", "MX"},
-			IntervalNumber:   v.IntervalNumber,
-			IntervalCount:    v.IntervalCount,
-			CreatedAt:        batchTimeRounded,
-			LocalProvenance:  true,
-		}
-	}
 
 	allowedAge := 14 * 24 * time.Hour
-	transformer, err := NewTransformer(10, 1, allowedAge, time.Hour, false)
+	transformer, err := NewTransformer(&testConfig{10, 1, allowedAge, time.Hour, maxSymptomOnsetDays, false})
 	if err != nil {
 		t.Fatalf("NewTransformer returned unexpected error: %v", err)
 	}
 	ctx := context.Background()
-	got, err := transformer.TransformPublish(ctx, source, batchTime)
-	if err != nil {
-		t.Fatalf("TransformPublish returned unexpected error: %v", err)
-	}
 
-	for i := range want {
-		if diff := cmp.Diff(want[i], got[i]); diff != "" {
-			t.Errorf("TransformPublish mismatch (-want +got):\n%v", diff)
-		}
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			got, err := transformer.TransformPublish(ctx, tc.Publish, tc.Claims, batchTime)
+			if err != nil {
+				t.Fatalf("TransformPublish returned unexpected error: %v", err)
+			}
+
+			if diff := cmp.Diff(tc.Want, got); diff != "" {
+				t.Errorf("TransformPublish mismatch (-want +got):\n%v", diff)
+			}
+		})
 	}
 }
 
@@ -466,7 +737,7 @@ func TestTransformOverlapping(t *testing.T) {
 	cases := []struct {
 		name                string
 		source              verifyapi.Publish
-		maxSameIntervalKeys int
+		maxSameIntervalKeys uint
 		error               string
 	}{
 		{
@@ -595,11 +866,11 @@ func TestTransformOverlapping(t *testing.T) {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx := context.Background()
-			transformer, err := NewTransformer(10, tc.maxSameIntervalKeys, allowedAge, time.Hour, false)
+			transformer, err := NewTransformer(&testConfig{10, tc.maxSameIntervalKeys, allowedAge, time.Hour, maxSymptomOnsetDays, false})
 			if err != nil {
 				t.Fatalf("NewTransformer returned unexpected error: %v", err)
 			}
-			_, err = transformer.TransformPublish(ctx, &tc.source, now)
+			_, err = transformer.TransformPublish(ctx, &tc.source, nil, now)
 			if err != nil && tc.error == "" {
 				t.Fatalf("unexpected error, want: nil, got: %v", err)
 			} else if err != nil && !strings.Contains(err.Error(), tc.error) {
@@ -667,15 +938,15 @@ func TestApplyOverrides(t *testing.T) {
 			Overrides: []verifyapi.TransmissionRiskOverride{
 				{
 					SinceRollingInterval: 5,
-					TranismissionRisk:    5,
+					TransmissionRisk:     5,
 				},
 				{
 					SinceRollingInterval: 3,
-					TranismissionRisk:    3,
+					TransmissionRisk:     3,
 				},
 				{
 					SinceRollingInterval: 0,
-					TranismissionRisk:    0,
+					TransmissionRisk:     0,
 				},
 			},
 			Want: []verifyapi.ExposureKey{
@@ -726,11 +997,11 @@ func TestApplyOverrides(t *testing.T) {
 			Overrides: []verifyapi.TransmissionRiskOverride{
 				{
 					SinceRollingInterval: 4,
-					TranismissionRisk:    5, // anything effective at time >= 4 gets TR 5.
+					TransmissionRisk:     5, // anything effective at time >= 4 gets TR 5.
 				},
 				{
 					SinceRollingInterval: 0,
-					TranismissionRisk:    2, // 2 since beginning of time.
+					TransmissionRisk:     2, // 2 since beginning of time.
 				},
 			},
 			Want: []verifyapi.ExposureKey{
@@ -769,7 +1040,7 @@ func TestApplyOverrides(t *testing.T) {
 			Overrides: []verifyapi.TransmissionRiskOverride{
 				{
 					SinceRollingInterval: 4,
-					TranismissionRisk:    5, // anything effective at time >= 4 gets TR 5.
+					TransmissionRisk:     5, // anything effective at time >= 4 gets TR 5.
 				},
 			},
 			Want: []verifyapi.ExposureKey{
@@ -799,3 +1070,72 @@ func TestApplyOverrides(t *testing.T) {
 		})
 	}
 }
+
+func TestDaysFromSymptomOnset(t *testing.T) {
+	now := time.Now().UTC()
+
+	cases := []struct {
+		name  string
+		onset int32
+		check int32
+		want  int32
+	}{
+		{
+			name:  "exact_match",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now),
+			want:  0,
+		},
+		{
+			name:  "next_day",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(24 * time.Hour)),
+			want:  1,
+		},
+		{
+			name:  "next_day_round_down",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(35 * time.Hour)),
+			want:  1,
+		},
+		{
+			name:  "next_day_round_up",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(37 * time.Hour)),
+			want:  2,
+		},
+		{
+			name:  "previous_day",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(-24 * time.Hour)),
+			want:  -1,
+		},
+		{
+			name:  "previous_day_round_down",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(-25 * time.Hour)),
+			want:  -1,
+		},
+		{
+			name:  "previous_day_round_up",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(-47 * time.Hour)),
+			want:  -2,
+		},
+		{
+			name:  "multiple_days",
+			onset: IntervalNumber(now),
+			check: IntervalNumber(now.Add(8*24*time.Hour + 2*time.Hour)),
+			want:  8,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := DaysFromSymptomOnset(tc.onset, tc.check)
+			if tc.want != got {
+				t.Fatalf("wrong day instance between %v and %v, got: %v want: %v", tc.onset, tc.check, got, tc.want)
+			}
+		})
+	}
+}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index 74c9640..c8878c7 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -215,7 +215,7 @@ func TestPublishWithBypass(t *testing.T) {
 			Error: "unable to validate diagnosis verification: token contains an invalid number of segments",
 		},
 		{
-			Name:       "valid HA certificate",
+			Name:       "valid_HA_certificate",
 			SigningKey: newSigningKey(t),
 			HealthAuthority: &vermodel.HealthAuthority{
 				Issuer:   "doh.my.gov",
@@ -241,7 +241,7 @@ func TestPublishWithBypass(t *testing.T) {
 			Code: http.StatusOK,
 		},
 		{
-			Name:       "valid HA certificate with overrides",
+			Name:       "valid_HA_certificate_with_overrides",
 			SigningKey: newSigningKey(t),
 			HealthAuthority: &vermodel.HealthAuthority{
 				Issuer:   "doh.my.gov",
@@ -266,7 +266,7 @@ func TestPublishWithBypass(t *testing.T) {
 			},
 			Overrides: []verifyapi.TransmissionRiskOverride{
 				{
-					TranismissionRisk:    8,
+					TransmissionRisk:     8,
 					SinceRollingInterval: 0,
 				},
 			},
@@ -340,7 +340,7 @@ func TestPublishWithBypass(t *testing.T) {
 			// And set up publish handler up front.
 			config := Config{}
 			config.AuthorizedApp.CacheDuration = time.Nanosecond
-			config.TruncateWindow = time.Second
+			config.CreatedAtTruncateWindow = time.Second
 			config.MaxKeysOnPublish = 20
 			config.MaxSameStartIntervalKeys = 2
 			config.MaxIntervalAge = 14 * 24 * time.Hour
@@ -454,17 +454,21 @@ func TestPublishWithBypass(t *testing.T) {
 					if key, err := base64util.DecodeString(k.Key); err != nil {
 						t.Fatal(err)
 					} else {
-						want = append(want,
-							&model.Exposure{
-								ExposureKey:      key,
-								AppPackageName:   tc.Publish.AppPackageName,
-								TransmissionRisk: k.TransmissionRisk,
-								IntervalNumber:   k.IntervalNumber,
-								IntervalCount:    k.IntervalCount,
-								Regions:          tc.Publish.Regions,
-								LocalProvenance:  true,
-								FederationSyncID: 0,
-							})
+						next := model.Exposure{
+							ExposureKey:      key,
+							AppPackageName:   tc.Publish.AppPackageName,
+							TransmissionRisk: k.TransmissionRisk,
+							IntervalNumber:   k.IntervalNumber,
+							IntervalCount:    k.IntervalCount,
+							Regions:          tc.Publish.Regions,
+							LocalProvenance:  true,
+							FederationSyncID: 0,
+						}
+						if tc.HealthAuthority != nil {
+							next.SetHealthAuthorityID(tc.HealthAuthority.ID)
+						}
+
+						want = append(want, &next)
 					}
 				}
 
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
index 1fa5c67..fd2b93e 100644
--- a/internal/verification/phaverify_test.go
+++ b/internal/verification/phaverify_test.go
@@ -184,7 +184,7 @@ func TestVerifyCertificate(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
+			verifiedClaims, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
 			if err != nil {
 				if !strings.Contains(err.Error(), tc.Error) {
 					t.Fatalf("wanted error '%v', got error '%v'", tc.Error, err.Error())
@@ -192,8 +192,10 @@ func TestVerifyCertificate(t *testing.T) {
 			} else if tc.Error != "" {
 				t.Fatalf("wanted error '%v', but got nil", tc.Error)
 			}
-			if len(overrides) != 0 {
-				t.Errorf("wanted no overrides, got %v", overrides)
+			if verifiedClaims != nil {
+				if len(verifiedClaims.TransmissionRisks) != 0 {
+					t.Errorf("wanted no overrides, got %v", verifiedClaims.TransmissionRisks)
+				}
 			}
 		})
 	}