Repository: google/exposure-notifications-server
Pull request #1131 (2020-10-30 23:00:09)

adjust symptom onset handling
## Proposed Changes

* "unreasonable" symptom onset intervals < 28d ago by default will be treated as if 0 is passed in. 
* keys with a valid symptom onset interval but fall outside the range of -14d..14d will be dropped (restores old / correct behavior.) THIS IS A CHANGE - currently those keys would get adjusted to nil, which would cause default
* if NO symptom onset date is passed in, we tread the symptom onset day as 4 days ago and adjust the data on the TEKs accordingly

This modeling is being adjusted in consultation with the Apple+Google clinical teams working on EN.

**Release Note**

```release-note
BEHAVIOR CHANGE: The handling of symptom onset intervals is changing. Any symptom onsets older than 28 days ago (rounded down to UTC midnight) will be treated as invalid and as if 0 was passed in. Keys with a valid symptom onset days outside of -14..14 will be dropped. If there is not symptom onset passed, the symptom onset interval is set to 4 days ago.
Timing flags can be adjusted via environment variables. Default values are recommended.
```

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/1131#" 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":[]} -->
mikehelmick: /hold 
until monday - want to give people a chance to comment
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 | a8c7b400741b08ae11a9fa1457110fe096b8265d | [link](https://oss-prow.knative.dev/view/gs/oss-prow/pr-logs/pull/google_exposure-notifications-server/1131/pull-en-server-release-unit/1322312371955306496) | `/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: /hold cancel
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/1131#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/1131#pullrequestreview-526911874" title="Approved">whaught</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,whaught]

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 f14a44b..17a7d08 100644
--- a/internal/generate/config.go
+++ b/internal/generate/config.go
@@ -45,14 +45,14 @@ type Config struct {
 	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"`
+	MaxMagnitudeSymptomOnsetDays uint          `env:"MAX_SYMPTOM_ONSET_DAYS, default=14"`
+	MaxSypmtomOnsetReportDays    uint          `env:"MAX_VALID_SYMPOTOM_ONSET_REPORT_DAYS, default=28"`
 	CreatedAtTruncateWindow      time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
 	DefaultRegion                string        `env:"DEFAULT_REGION, default=US"`
 	ChanceOfKeyRevision          int           `env:"CHANCE_OF_KEY_REVISION, default=30"` // 0-100 are valid values.
 	ChanceOfTraveler             int           `env:"CHANCE_OF_TRAVELER, default=20"`     // 0-100 are valid values
 	KeyRevisionDelay             time.Duration `env:"KEY_REVISION_DELAY, default=2h"`     // key revision will be forward dates this amount.
-	UseDefaultSymptomOnset       bool          `env:"USE_DEFAULT_SYMPTOM_ONSET_DAYS, default=true"`
-	SymptomOnsetDays             uint          `env:"DEFAULT_SYMPTOM_ONSET_DAYS, default=10"`
+	SymptomOnsetDaysAgo          uint          `env:"DEFAULT_SYMPTOM_ONSET_DAYS_AGO, default=4"`
 }
 
 func (c *Config) MaxExposureKeys() uint {
@@ -75,12 +75,12 @@ func (c *Config) MaxSymptomOnsetDays() uint {
 	return c.MaxMagnitudeSymptomOnsetDays
 }
 
-func (c *Config) UseDefaultSymptomOnsetDays() bool {
-	return c.UseDefaultSymptomOnset
+func (c *Config) MaxValidSymptomOnsetReportDays() uint {
+	return c.MaxSypmtomOnsetReportDays
 }
 
-func (c *Config) DefaultSymptomOnsetDays() int32 {
-	return int32(c.SymptomOnsetDays)
+func (c *Config) DefaultSymptomOnsetDaysAgo() uint {
+	return c.SymptomOnsetDaysAgo
 }
 
 func (c *Config) DebugReleaseSameDayKeys() bool {
diff --git a/internal/integration/helpers.go b/internal/integration/helpers.go
index a565548..56a37c0 100644
--- a/internal/integration/helpers.go
+++ b/internal/integration/helpers.go
@@ -207,18 +207,19 @@ func NewTestServer(tb testing.TB) (*serverenv.ServerEnv, *Client) {
 	}
 	mux.Handle("/key-rotation/", http.StripPrefix("/key-rotation", rotationServer.Routes(ctx)))
 
-	// Publish
-	publishConfig := &publish.Config{
-		RevisionToken:            revConfig,
-		MaxKeysOnPublish:         15,
-		MaxSameStartIntervalKeys: 2,
-		MaxIntervalAge:           360 * time.Hour,
-		CreatedAtTruncateWindow:  1 * time.Second,
-		ReleaseSameDayKeys:       true,
-		RevisionKeyCacheDuration: time.Second,
-	}
-
-	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
+	// Parse the config to load default values.
+	publishConfig := publish.Config{}
+	envconfig.ProcessWith(ctx, &publishConfig, envconfig.OsLookuper())
+	// Make overrides.
+	publishConfig.RevisionToken = revConfig
+	publishConfig.MaxKeysOnPublish = 15
+	publishConfig.MaxSameStartIntervalKeys = 2
+	publishConfig.MaxIntervalAge = 360 * time.Hour
+	publishConfig.CreatedAtTruncateWindow = time.Second
+	publishConfig.ReleaseSameDayKeys = true
+	publishConfig.RevisionKeyCacheDuration = time.Second
+
+	publishHandler, err := publish.NewHandler(ctx, &publishConfig, env)
 	if err != nil {
 		tb.Fatal(err)
 	}
diff --git a/internal/publish/config.go b/internal/publish/config.go
index df6d192..ebfad21 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -16,6 +16,7 @@
 package publish
 
 import (
+	"fmt"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
@@ -27,6 +28,7 @@ import (
 	"github.com/google/exposure-notifications-server/pkg/keys"
 	"github.com/google/exposure-notifications-server/pkg/observability"
 	"github.com/google/exposure-notifications-server/pkg/secrets"
+	"github.com/hashicorp/go-multierror"
 )
 
 // Compile-time check to assert this config matches requirements.
@@ -51,15 +53,21 @@ type Config struct {
 	Port             string `env:"PORT, default=8080"`
 	MaxKeysOnPublish uint   `env:"MAX_KEYS_ON_PUBLISH, default=30"`
 	// Provides compatibility w/ 1.5 release.
-	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=10"`
-	CreatedAtTruncateWindow      time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
-
-	// If set, TEKs that arrive without a days since symptom onset (i.e. no symptom onset date)
-	// will be set to the default symptom onset days.
-	UseDefaultSymptomOnset bool `env:"USE_DEFAULT_SYMPTOM_ONSET_DAYS, default=true"`
-	SymptomOnsetDays       uint `env:"DEFAULT_SYMPTOM_ONSET_DAYS, default=10"`
+	MaxSameStartIntervalKeys uint          `env:"MAX_SAME_START_INTERVAL_KEYS, default=3"`
+	MaxIntervalAge           time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
+	CreatedAtTruncateWindow  time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+
+	// Symptom onset settings.
+	// Maximum valid range. TEKs presneted with values outside this range, but still "reasonable" will not be saved.
+	MaxMagnitudeSymptomOnsetDays uint `env:"MAX_SYMPTOM_ONSET_DAYS, default=14"`
+	// MaxValidSypmtomOnsetReportDays indicates how many days would be considered
+	// a valid symptom onset report (-val..+val). Anything outside
+	// that range would be subject to the default symptom onset flags (see below).
+	MaxSypmtomOnsetReportDays uint `env:"MAX_VALID_SYMPOTOM_ONSET_REPORT_DAYS, default=28"`
+
+	// TEKs that arrive without a days since symptom onset (i.e. no symptom onset date),
+	// then the upload date minus DEFAULT_SYMPTOM_ONSET_DAYS_AGO is used.
+	SymptomOnsetDaysAgo uint `env:"DEFAULT_SYMPTOM_ONSET_DAYS_AGO, default=4"`
 
 	ResponsePaddingMinBytes int64 `env:"RESPONSE_PADDING_MIN_BYTES, default=1024"`
 	ResponsePaddingRange    int64 `env:"RESPONSE_PADDING_RANGE, default=1024"`
@@ -91,6 +99,21 @@ type Config struct {
 	DebugLogBadCertificates bool `env:"DEBUG_LOG_BAD_CERTIFICATES"`
 }
 
+func (c *Config) Validate() error {
+	var result *multierror.Error
+
+	if c.MaxMagnitudeSymptomOnsetDays == 0 {
+		result = multierror.Append(result,
+			fmt.Errorf("env var `MAX_SYMPTOM_ONSET_DAYS` must be > 0, got: %v", c.MaxMagnitudeSymptomOnsetDays))
+	}
+	if c.MaxSypmtomOnsetReportDays == 0 {
+		result = multierror.Append(result,
+			fmt.Errorf("env var `MAX_VALID_SYMPOTOM_ONSET_REPORT_DAYS` must be > 0, got: %v", c.MaxSypmtomOnsetReportDays))
+	}
+
+	return result.ErrorOrNil()
+}
+
 func (c *Config) MaxExposureKeys() uint {
 	return c.MaxKeysOnPublish
 }
@@ -111,12 +134,12 @@ func (c *Config) MaxSymptomOnsetDays() uint {
 	return c.MaxMagnitudeSymptomOnsetDays
 }
 
-func (c *Config) UseDefaultSymptomOnsetDays() bool {
-	return c.UseDefaultSymptomOnset
+func (c *Config) MaxValidSymptomOnsetReportDays() uint {
+	return c.MaxSypmtomOnsetReportDays
 }
 
-func (c *Config) DefaultSymptomOnsetDays() int32 {
-	return int32(c.SymptomOnsetDays)
+func (c *Config) DefaultSymptomOnsetDaysAgo() uint {
+	return c.SymptomOnsetDaysAgo
 }
 
 func (c *Config) DebugReleaseSameDayKeys() bool {
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index e035cd8..2f5d682 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -28,6 +28,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/verification"
 	"github.com/google/exposure-notifications-server/pkg/base64util"
 	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/timeutils"
 	"github.com/hashicorp/go-multierror"
 
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
@@ -379,21 +380,21 @@ type TransformerConfig interface {
 	MaxIntervalStartAge() time.Duration
 	TruncateWindow() time.Duration
 	MaxSymptomOnsetDays() uint
-	UseDefaultSymptomOnsetDays() bool
-	DefaultSymptomOnsetDays() int32
+	MaxValidSymptomOnsetReportDays() uint
+	DefaultSymptomOnsetDaysAgo() 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
-	maxSymptomOnsetDays        float64 // to avoid casting in comparisons
-	useDefaultSymptomOnsetDays bool
-	defaultSymptomOnsetDays    int32
-	debugReleaseSameDay        bool // If true, still valid keys are not embargoed.
+	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
+	maxSymptomOnsetDays            float64 // to avoid casting in comparisons
+	maxValidSymptomOnsetReportDays uint
+	defaultSymptomOnsetDaysAgo     uint
+	debugReleaseSameDay            bool // If true, still valid keys are not embargoed.
 }
 
 // NewTransformer creates a transformer for turning publish API requests into
@@ -407,14 +408,14 @@ func NewTransformer(config TransformerConfig) (*Transformer, error) {
 		return nil, fmt.Errorf("maxSameDayKeys must be >= 1, got %v", config.MaxSameDayKeys())
 	}
 	return &Transformer{
-		maxExposureKeys:            int(config.MaxExposureKeys()),
-		maxSameDayKeys:             int(config.MaxSameDayKeys()),
-		maxIntervalStartAge:        config.MaxIntervalStartAge(),
-		truncateWindow:             config.TruncateWindow(),
-		maxSymptomOnsetDays:        float64(config.MaxSymptomOnsetDays()),
-		useDefaultSymptomOnsetDays: config.UseDefaultSymptomOnsetDays(),
-		defaultSymptomOnsetDays:    config.DefaultSymptomOnsetDays(),
-		debugReleaseSameDay:        config.DebugReleaseSameDayKeys(),
+		maxExposureKeys:                int(config.MaxExposureKeys()),
+		maxSameDayKeys:                 int(config.MaxSameDayKeys()),
+		maxIntervalStartAge:            config.MaxIntervalStartAge(),
+		truncateWindow:                 config.TruncateWindow(),
+		maxSymptomOnsetDays:            float64(config.MaxSymptomOnsetDays()),
+		maxValidSymptomOnsetReportDays: config.MaxValidSymptomOnsetReportDays(),
+		defaultSymptomOnsetDaysAgo:     config.DefaultSymptomOnsetDaysAgo(),
+		debugReleaseSameDay:            config.DebugReleaseSameDayKeys(),
 	}, nil
 }
 
@@ -559,11 +560,33 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		BatchWindow:           t.truncateWindow,
 	}
 
-	onsetInterval := inData.SymptomOnsetInterval
-	// If the symtom onset interval provided on publish is too old to be relevant
-	// and one was provided in the verification certificate, take that one.
-	if onsetInterval < settings.MinStartInterval && claims != nil && claims.SymptomOnsetInterval > 0 {
-		onsetInterval = int32(claims.SymptomOnsetInterval)
+	// For validating key timing information, can't be newer than now.
+	currentInterval := IntervalNumber(batchTime)
+	// For validating the passed in symptom interval, relative to current time.
+	minSymptomInterval := IntervalNumber(
+		timeutils.UTCMidnight(timeutils.SubtractDays(batchTime, t.maxValidSymptomOnsetReportDays)))
+
+	// Base level, assume there is no symptom onset interval present.
+	onsetInterval := int32(0)
+	if pubInt := inData.SymptomOnsetInterval; pubInt < currentInterval && pubInt >= minSymptomInterval {
+		onsetInterval = pubInt
+	} else if claims != nil {
+		if vcInt := int32(claims.SymptomOnsetInterval); vcInt < currentInterval && vcInt >= minSymptomInterval {
+			// If the symtom onset interval provided on publish is too old to be relevant
+			// and one was provided in the verification certificate, take that one.
+			onsetInterval = int32(claims.SymptomOnsetInterval)
+		}
+	}
+	// If we reach this point, and onsetInterval is 0 OR if the onset interval
+	// is "unreasonable" then we default the onsetInterval to 4 (*configurable)
+	// days ago to approximate symptom onset.
+	//
+	// There are launched applications using this sever that rely on this
+	// behavior - that are passing invalid symotom onset interviews, those
+	// are screened about above when the onsetInterval is set.
+	if daysSince := math.Abs(float64(DaysFromSymptomOnset(onsetInterval, currentInterval))); onsetInterval == 0 || daysSince > float64(t.maxValidSymptomOnsetReportDays) {
+		logger.Debugw("defaulting days since symptom onset")
+		onsetInterval = IntervalNumber(timeutils.SubtractDays(batchTime, t.defaultSymptomOnsetDaysAgo))
 	}
 
 	// Regions are a multi-value property, uppercase them for storage.
@@ -597,29 +620,18 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		// Set days since onset, either from the API or from the verified claims (see above).
 		if onsetInterval > 0 {
 			daysSince := DaysFromSymptomOnset(onsetInterval, exposure.IntervalNumber)
-			// Check if the magnitude of this value is too large. If it is too large,
-			// we won't want to set a days since symptom onset value on the TEK
-			// itself, but we do want to warn the application developer that this
-			// value (not TEK) was dropped.
-			//
-			// There are launched applications using this sever that rely on this
-			// behavior.
-			//
 			// Note that previously this returned an error, but this broke the iOS
 			// implementation since it is unable to handle partial success. As such,
 			// it was converted to a warning that's a separate field in the API
 			// response.
 			if abs := math.Abs(float64(daysSince)); abs > t.maxSymptomOnsetDays {
 				logger.Debugw("setting days since symptom onset to null on key due to symptom onset magnitude too high", "daysSince", daysSince)
-				transformWarnings = append(transformWarnings, fmt.Sprintf("key %d symptom onset is too large, %v > %v - saving without days since symptom onset", i, abs, t.maxSymptomOnsetDays))
-			} else {
-				// The value is within acceptable range, save it.
-				exposure.SetDaysSinceSymptomOnset(daysSince)
+				transformWarnings = append(transformWarnings, fmt.Sprintf("key %d symptom onset is too large, %v > %v - saving without this key", i, abs, t.maxSymptomOnsetDays))
+				continue
 			}
-		}
-		// See if a default symptom onset days should be applied.
-		if exposure.DaysSinceSymptomOnset == nil && t.useDefaultSymptomOnsetDays {
-			exposure.SetDaysSinceSymptomOnset(t.defaultSymptomOnsetDays)
+
+			// The value is within acceptable range, save it.
+			exposure.SetDaysSinceSymptomOnset(daysSince)
 		}
 
 		exposure.Traveler = inData.Traveler
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 7eb1c5a..7923005 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -94,6 +94,10 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("revision.New: %w", err)
 	}
 
+	if err := config.Validate(); err != nil {
+		return nil, fmt.Errorf("config validation: %w", err)
+	}
+
 	return &PublishHandler{
 		serverenv:             env,
 		transformer:           transformer,
diff --git a/pkg/timeutils/math.go b/pkg/timeutils/math.go
new file mode 100644
index 0000000..309ba8e
--- /dev/null
+++ b/pkg/timeutils/math.go
@@ -0,0 +1,24 @@
+// 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.
+
+// Package timeutils defines functions to close the gaps present in Golang's
+// default implementation of Time.
+package timeutils
+
+import "time"
+
+// SubtractDays subtracts the specified number of days from the given time.
+func SubtractDays(t time.Time, days uint) time.Time {
+	return t.Add(time.Hour * 24 * time.Duration(days) * -1)
+}


Test patch:
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index eb5b21a..820a73b 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -417,6 +417,9 @@ func getExposures(db *database.DB, criteria publishdb.IterateExposuresCriteria)
 // output).
 func exportedKeysFrom(tb testing.TB, keys []verifyapi.ExposureKey) []*export.TemporaryExposureKey {
 	s := make([]*export.TemporaryExposureKey, len(keys))
+	sort.Slice(keys, func(i, j int) bool {
+		return keys[i].IntervalNumber >= keys[j].IntervalNumber
+	})
 	for i, key := range keys {
 		decoded, err := base64util.DecodeString(key.Key)
 		if err != nil {
@@ -428,6 +431,8 @@ func exportedKeysFrom(tb testing.TB, keys []verifyapi.ExposureKey) []*export.Tem
 			TransmissionRiskLevel:      proto.Int32(int32(key.TransmissionRisk)),
 			RollingStartIntervalNumber: proto.Int32(key.IntervalNumber),
 			ReportType:                 export.TemporaryExposureKey_CONFIRMED_TEST.Enum(),
+			// Keys are generated 1 day ago and then -1 day for each additional.
+			DaysSinceOnsetOfSymptoms: proto.Int32(int32(3 - i)),
 		}
 	}
 
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index 6d26788..3e6e920 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -37,18 +37,19 @@ import (
 )
 
 const (
-	maxSymptomOnsetDays = 14
+	maxSymptomOnsetDays            = 14
+	maxValidSymptomOnsetReportDays = 28
 )
 
 type testConfig struct {
-	maxExposureKeys            uint
-	maxSameDayKeys             uint
-	maxIntervalStartAge        time.Duration
-	truncateWindow             time.Duration
-	maxSymptomOnsetDays        uint
-	useDefaultSymptomOnsetDays bool
-	defaultSymptomOnsetDays    int32
-	debugReleaseSameDay        bool
+	maxExposureKeys                uint
+	maxSameDayKeys                 uint
+	maxIntervalStartAge            time.Duration
+	truncateWindow                 time.Duration
+	maxSymptomOnsetDays            uint
+	maxValidSymptomOnsetReportDays uint
+	defaultSymptomOnsetDays        uint
+	debugReleaseSameDay            bool
 }
 
 func (c *testConfig) MaxExposureKeys() uint {
@@ -71,11 +72,11 @@ func (c *testConfig) MaxSymptomOnsetDays() uint {
 	return c.maxSymptomOnsetDays
 }
 
-func (c *testConfig) UseDefaultSymptomOnsetDays() bool {
-	return c.useDefaultSymptomOnsetDays
+func (c *testConfig) MaxValidSymptomOnsetReportDays() uint {
+	return c.maxValidSymptomOnsetReportDays
 }
 
-func (c *testConfig) DefaultSymptomOnsetDays() int32 {
+func (c *testConfig) DefaultSymptomOnsetDaysAgo() uint {
 	return c.defaultSymptomOnsetDays
 }
 
@@ -492,6 +493,7 @@ func TestTransform(t *testing.T) {
 	wantRegions := []string{"US", "CA", "MX"}
 	batchTime := captureStartTime.Add(time.Hour * 24 * 7)
 	batchTimeRounded := TruncateWindow(batchTime, time.Hour)
+	defaultSymptomOnset := uint(4)
 
 	cases := []struct {
 		Name         string
@@ -537,44 +539,48 @@ func TestTransform(t *testing.T) {
 			Claims:  nil,
 			Want: []*Exposure{
 				{
-					ExposureKey:      testKeys[0],
-					IntervalNumber:   intervalNumber,
-					IntervalCount:    verifyapi.MaxIntervalCount,
-					TransmissionRisk: 1,
-					AppPackageName:   appPackage,
-					Regions:          wantRegions,
-					CreatedAt:        batchTimeRounded,
-					LocalProvenance:  true,
+					ExposureKey:           testKeys[0],
+					IntervalNumber:        intervalNumber,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      1,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					DaysSinceSymptomOnset: int32Ptr(-3),
 				},
 				{
-					ExposureKey:      testKeys[1],
-					IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
-					IntervalCount:    verifyapi.MaxIntervalCount,
-					TransmissionRisk: 2,
-					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,
+					DaysSinceSymptomOnset: int32Ptr(-2),
 				},
 				{
-					ExposureKey:      testKeys[2],
-					IntervalNumber:   intervalNumber + 2*verifyapi.MaxIntervalCount,
-					IntervalCount:    verifyapi.MaxIntervalCount,
-					TransmissionRisk: 3,
-					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,
+					DaysSinceSymptomOnset: int32Ptr(-1),
 				},
 				{
-					ExposureKey:      testKeys[3],
-					IntervalNumber:   intervalNumber + 3*verifyapi.MaxIntervalCount,
-					IntervalCount:    42,
-					TransmissionRisk: 4,
-					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,
+					DaysSinceSymptomOnset: int32Ptr(0),
 				},
 			},
 		},
@@ -603,26 +609,28 @@ func TestTransform(t *testing.T) {
 			},
 			Want: []*Exposure{
 				{
-					ExposureKey:      testKeys[0],
-					IntervalNumber:   intervalNumber,
-					IntervalCount:    verifyapi.MaxIntervalCount,
-					TransmissionRisk: verifyapi.TransmissionRiskConfirmedStandard,
-					AppPackageName:   appPackage,
-					Regions:          wantRegions,
-					CreatedAt:        batchTimeRounded,
-					LocalProvenance:  true,
-					ReportType:       verifyapi.ReportTypeConfirmed,
+					ExposureKey:           testKeys[0],
+					IntervalNumber:        intervalNumber,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskConfirmedStandard,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeConfirmed,
+					DaysSinceSymptomOnset: int32Ptr(-3),
 				},
 				{
-					ExposureKey:      testKeys[1],
-					IntervalNumber:   intervalNumber + verifyapi.MaxIntervalCount,
-					IntervalCount:    verifyapi.MaxIntervalCount,
-					TransmissionRisk: verifyapi.TransmissionRiskConfirmedStandard,
-					AppPackageName:   appPackage,
-					Regions:          wantRegions,
-					CreatedAt:        batchTimeRounded,
-					LocalProvenance:  true,
-					ReportType:       verifyapi.ReportTypeConfirmed,
+					ExposureKey:           testKeys[1],
+					IntervalNumber:        intervalNumber + verifyapi.MaxIntervalCount,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskConfirmedStandard,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeConfirmed,
+					DaysSinceSymptomOnset: int32Ptr(-2),
 				},
 			},
 		},
@@ -786,7 +794,7 @@ func TestTransform(t *testing.T) {
 					},
 				},
 				HealthAuthorityID:    appPackage,
-				SymptomOnsetInterval: int32(intervalNumber + 1*verifyapi.MaxIntervalCount),
+				SymptomOnsetInterval: int32(intervalNumber + +verifyapi.MaxIntervalCount),
 			},
 			Regions: wantRegions,
 			Claims: &verification.VerifiedClaims{
@@ -837,32 +845,37 @@ func TestTransform(t *testing.T) {
 			},
 		},
 		{
-			Name: "symptom_onset_too_large",
+			Name: "user_provided_symptom_unreasonable",
 			Publish: &verifyapi.Publish{
 				Keys: []verifyapi.ExposureKey{
 					{
-						Key:            encodeKey(testKeys[6]),
-						IntervalNumber: intervalNumber - (4 * verifyapi.MaxIntervalCount),
+						Key:            encodeKey(testKeys[3]),
+						IntervalNumber: intervalNumber + 2*verifyapi.MaxIntervalCount,
 						IntervalCount:  verifyapi.MaxIntervalCount,
 					},
 					{
-						Key:            encodeKey(testKeys[7]),
-						IntervalNumber: intervalNumber - (1 * verifyapi.MaxIntervalCount),
+						Key:            encodeKey(testKeys[4]),
+						IntervalNumber: intervalNumber + 3*verifyapi.MaxIntervalCount,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+					{
+						Key:            encodeKey(testKeys[5]),
+						IntervalNumber: intervalNumber + 4*verifyapi.MaxIntervalCount,
 						IntervalCount:  verifyapi.MaxIntervalCount,
 					},
 				},
-				HealthAuthorityID: appPackage,
+				HealthAuthorityID:    appPackage,
+				SymptomOnsetInterval: 1,
 			},
 			Regions: wantRegions,
 			Claims: &verification.VerifiedClaims{
-				HealthAuthorityID:    27,
-				ReportType:           verifyapi.ReportTypeClinical,
-				SymptomOnsetInterval: uint32(intervalNumber - 16*verifyapi.MaxIntervalCount),
+				HealthAuthorityID: 27,
+				ReportType:        verifyapi.ReportTypeClinical,
 			},
 			Want: []*Exposure{
 				{
-					ExposureKey:           testKeys[6],
-					IntervalNumber:        intervalNumber - (4 * verifyapi.MaxIntervalCount),
+					ExposureKey:           testKeys[3],
+					IntervalNumber:        intervalNumber + 2*verifyapi.MaxIntervalCount,
 					IntervalCount:         verifyapi.MaxIntervalCount,
 					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
 					AppPackageName:        appPackage,
@@ -870,12 +883,12 @@ func TestTransform(t *testing.T) {
 					CreatedAt:             batchTimeRounded,
 					LocalProvenance:       true,
 					ReportType:            verifyapi.ReportTypeClinical,
-					DaysSinceSymptomOnset: int32Ptr(12),
+					DaysSinceSymptomOnset: int32Ptr(-1),
 					HealthAuthorityID:     int64Ptr(27),
 				},
 				{
-					ExposureKey:           testKeys[7],
-					IntervalNumber:        intervalNumber - (1 * verifyapi.MaxIntervalCount),
+					ExposureKey:           testKeys[4],
+					IntervalNumber:        intervalNumber + 3*verifyapi.MaxIntervalCount,
 					IntervalCount:         verifyapi.MaxIntervalCount,
 					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
 					AppPackageName:        appPackage,
@@ -883,21 +896,75 @@ func TestTransform(t *testing.T) {
 					CreatedAt:             batchTimeRounded,
 					LocalProvenance:       true,
 					ReportType:            verifyapi.ReportTypeClinical,
-					DaysSinceSymptomOnset: nil, // dropped since it was too large.
+					DaysSinceSymptomOnset: int32Ptr(0),
+					HealthAuthorityID:     int64Ptr(27),
+				},
+				{
+					ExposureKey:           testKeys[5],
+					IntervalNumber:        intervalNumber + 4*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),
 				},
 			},
-			Warnings: []string{"key 1 symptom onset is too large, 15 > 14 - saving without days since symptom onset"},
+		},
+		{
+			Name: "symptom_onset_too_large",
+			Publish: &verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:            encodeKey(testKeys[6]),
+						IntervalNumber: intervalNumber,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+					{
+						Key:            encodeKey(testKeys[7]),
+						IntervalNumber: intervalNumber + 1*verifyapi.MaxIntervalCount,
+						IntervalCount:  verifyapi.MaxIntervalCount,
+					},
+				},
+				HealthAuthorityID: appPackage,
+			},
+			Regions: wantRegions,
+			Claims: &verification.VerifiedClaims{
+				HealthAuthorityID:    27,
+				ReportType:           verifyapi.ReportTypeClinical,
+				SymptomOnsetInterval: uint32(intervalNumber - 14*verifyapi.MaxIntervalCount),
+			},
+			Want: []*Exposure{
+				{
+					ExposureKey:           testKeys[6],
+					IntervalNumber:        intervalNumber,
+					IntervalCount:         verifyapi.MaxIntervalCount,
+					TransmissionRisk:      verifyapi.TransmissionRiskClinical,
+					AppPackageName:        appPackage,
+					Regions:               wantRegions,
+					CreatedAt:             batchTimeRounded,
+					LocalProvenance:       true,
+					ReportType:            verifyapi.ReportTypeClinical,
+					DaysSinceSymptomOnset: int32Ptr(14),
+					HealthAuthorityID:     int64Ptr(27),
+				},
+			},
+			Warnings: []string{"key 1 symptom onset is too large, 15 > 14 - saving without this key"},
 		},
 	}
 
 	allowedAge := 14 * 24 * time.Hour
 	transformer, err := NewTransformer(&testConfig{
-		maxExposureKeys:     10,
-		maxSameDayKeys:      1,
-		maxIntervalStartAge: allowedAge,
-		truncateWindow:      time.Hour,
-		maxSymptomOnsetDays: maxSymptomOnsetDays,
+		maxExposureKeys:                10,
+		maxSameDayKeys:                 1,
+		maxIntervalStartAge:            allowedAge,
+		truncateWindow:                 time.Hour,
+		maxSymptomOnsetDays:            maxSymptomOnsetDays,
+		maxValidSymptomOnsetReportDays: maxValidSymptomOnsetReportDays,
+		defaultSymptomOnsetDays:        defaultSymptomOnset,
 	})
 	if err != nil {
 		t.Fatalf("NewTransformer returned unexpected error: %v", err)
@@ -935,61 +1002,68 @@ func TestTransform(t *testing.T) {
 
 func TestDefaultSymptomOnset(t *testing.T) {
 	now := time.Now()
-	intervalNumber := IntervalNumber(now) - 1
+	onsetDaysAgo := uint(4)
 
 	cases := []struct {
 		name               string
 		source             verifyapi.Publish
-		useOnsetDefault    bool
-		onsetDefault       int32
 		wantDaysSinceOnset *int32
 	}{
 		{
-			name: "use_default_when_not_set",
+			name: "more_than_onset_days_ago",
 			source: verifyapi.Publish{
 				Keys: []verifyapi.ExposureKey{
 					{
 						Key:              encodeKey(generateKey(t)),
-						IntervalNumber:   intervalNumber,
+						IntervalNumber:   IntervalNumber(timeutils.SubtractDays(now, onsetDaysAgo+1)),
 						IntervalCount:    verifyapi.MaxIntervalCount,
 						TransmissionRisk: 1,
 					},
 				},
 			},
-			useOnsetDefault:    true,
-			onsetDefault:       10,
-			wantDaysSinceOnset: proto.Int32(10),
+			wantDaysSinceOnset: int32Ptr(-1),
 		},
 		{
-			name: "do_not_set_default",
+			name: "exactly_onset_days_ago",
 			source: verifyapi.Publish{
 				Keys: []verifyapi.ExposureKey{
 					{
 						Key:              encodeKey(generateKey(t)),
-						IntervalNumber:   intervalNumber,
+						IntervalNumber:   IntervalNumber(timeutils.SubtractDays(now, onsetDaysAgo)),
 						IntervalCount:    verifyapi.MaxIntervalCount,
 						TransmissionRisk: 1,
 					},
 				},
 			},
-			useOnsetDefault:    false,
-			onsetDefault:       12,
-			wantDaysSinceOnset: nil,
+			wantDaysSinceOnset: int32Ptr(0),
+		},
+		{
+			name: "less_than_onset_days_ago",
+			source: verifyapi.Publish{
+				Keys: []verifyapi.ExposureKey{
+					{
+						Key:              encodeKey(generateKey(t)),
+						IntervalNumber:   IntervalNumber(timeutils.SubtractDays(now, onsetDaysAgo-1)),
+						IntervalCount:    verifyapi.MaxIntervalCount,
+						TransmissionRisk: 1,
+					},
+				},
+			},
+			wantDaysSinceOnset: int32Ptr(1),
 		},
 	}
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			allowedAge := 2 * 24 * time.Hour
+			allowedAge := 6 * 24 * time.Hour
 			transformer, err := NewTransformer(&testConfig{
-				maxExposureKeys:            10,
-				maxSameDayKeys:             1,
-				maxIntervalStartAge:        allowedAge,
-				truncateWindow:             time.Minute,
-				maxSymptomOnsetDays:        maxSymptomOnsetDays,
-				debugReleaseSameDay:        false,
-				useDefaultSymptomOnsetDays: tc.useOnsetDefault,
-				defaultSymptomOnsetDays:    tc.onsetDefault,
+				maxExposureKeys:         10,
+				maxSameDayKeys:          1,
+				maxIntervalStartAge:     allowedAge,
+				truncateWindow:          time.Minute,
+				maxSymptomOnsetDays:     maxSymptomOnsetDays,
+				debugReleaseSameDay:     false,
+				defaultSymptomOnsetDays: onsetDaysAgo,
 			})
 			if err != nil {
 				t.Fatal(err)
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index df0f34f..8b6a6f5 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -43,8 +43,10 @@ import (
 	"github.com/google/exposure-notifications-server/pkg/base64util"
 	"github.com/google/exposure-notifications-server/pkg/keys"
 	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/timeutils"
 	"github.com/google/exposure-notifications-server/pkg/util"
 	"github.com/jackc/pgx/v4"
+	"github.com/sethvargo/go-envconfig"
 
 	testutil "github.com/google/exposure-notifications-server/internal/utils"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
@@ -52,6 +54,7 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
 )
 
 type version int
@@ -431,6 +434,8 @@ func TestPublishWithBypass(t *testing.T) {
 				config.RevisionToken.KeyID = keyID
 				config.ResponsePaddingMinBytes = 100
 				config.ResponsePaddingRange = 100
+				config.MaxMagnitudeSymptomOnsetDays = 14
+				config.MaxSypmtomOnsetReportDays = 28
 				env := serverenv.New(ctx,
 					serverenv.WithDatabase(testDB),
 					serverenv.WithAuthorizedAppProvider(aaProvider),
@@ -628,7 +633,7 @@ func TestPublishWithBypass(t *testing.T) {
 							})
 							return out
 						})
-						ignoreCreatedAt := cmpopts.IgnoreFields(*want[0], "CreatedAt")
+						ignoreCreatedAt := cmpopts.IgnoreFields(*want[0], "CreatedAt", "DaysSinceSymptomOnset")
 
 						if diff := cmp.Diff(want, got, sorter, ignoreCreatedAt, cmpopts.IgnoreUnexported(model.Exposure{})); diff != "" {
 							t.Errorf("mismatch (-want, +got):\n%s", diff)
@@ -739,8 +744,10 @@ func TestKeyRevision(t *testing.T) {
 	signingKey := testutil.GetSigningKey(t)
 	testutil.InitalizeVerificationDB(ctx, t, testDB, healthAuthority, healthAuthorityKey, signingKey)
 
+	defaultSymptomOnsetDaysAgo := uint(1)
 	// And set up publish handler up front.
 	config := Config{}
+	envconfig.ProcessWith(ctx, &config, envconfig.OsLookuper())
 	config.AuthorizedApp.CacheDuration = time.Nanosecond
 	config.CreatedAtTruncateWindow = time.Second
 	config.MaxKeysOnPublish = 20
@@ -748,6 +755,8 @@ func TestKeyRevision(t *testing.T) {
 	config.MaxIntervalAge = 14 * 24 * time.Hour
 	config.ResponsePaddingMinBytes = 100
 	config.ResponsePaddingRange = 100
+	config.SymptomOnsetDaysAgo = defaultSymptomOnsetDaysAgo
+	config.AllowPartialRevisions = false
 	aaProvider, err := authorizedapp.NewDatabaseProvider(ctx, testDB, config.AuthorizedAppConfig())
 	if err != nil {
 		t.Fatal(err)
@@ -780,6 +789,10 @@ func TestKeyRevision(t *testing.T) {
 
 	pubDB := pubdb.New(testDB)
 
+	// the first key in each publish will be at this time.
+	utcDay := timeutils.UTCMidnight(time.Now())
+	intervalNumber := uint32(utcDay.Unix()/600) - verifyapi.MaxIntervalCount
+
 	cases := []struct {
 		Name               string
 		Publish            verifyapi.Publish
@@ -983,11 +996,12 @@ func TestKeyRevision(t *testing.T) {
 			// Do the initial insert
 			{
 				cfg := &testutil.JWTConfig{
-					HealthAuthority:    healthAuthority,
-					HealthAuthorityKey: healthAuthorityKey,
-					ExposureKeys:       tc.Publish.Keys,
-					Key:                signingKey.Key,
-					ReportType:         verifyapi.ReportTypeClinical,
+					HealthAuthority:      healthAuthority,
+					HealthAuthorityKey:   healthAuthorityKey,
+					ExposureKeys:         tc.Publish.Keys,
+					Key:                  signingKey.Key,
+					SymptomOnsetInterval: intervalNumber,
+					ReportType:           verifyapi.ReportTypeClinical,
 				}
 				verification, salt := testutil.IssueJWT(t, cfg)
 				tc.Publish.VerificationPayload = verification
@@ -1035,11 +1049,12 @@ func TestKeyRevision(t *testing.T) {
 				tc.Publish = *publishData
 
 				cfg := &testutil.JWTConfig{
-					HealthAuthority:    healthAuthority,
-					HealthAuthorityKey: healthAuthorityKey,
-					ExposureKeys:       tc.Publish.Keys,
-					Key:                signingKey.Key,
-					ReportType:         tc.ReportTypeMesser(verifyapi.ReportTypeConfirmed),
+					HealthAuthority:      healthAuthority,
+					HealthAuthorityKey:   healthAuthorityKey,
+					ExposureKeys:         tc.Publish.Keys,
+					Key:                  signingKey.Key,
+					SymptomOnsetInterval: intervalNumber,
+					ReportType:           tc.ReportTypeMesser(verifyapi.ReportTypeConfirmed),
 				}
 
 				if messer := tc.VerificationMesser; messer != nil {
@@ -1075,7 +1090,7 @@ func TestKeyRevision(t *testing.T) {
 				}
 
 				if response.Code != tc.RevErrorCode {
-					t.Fatalf("wrong code on revision, want %#v, got %#v", tc.RevErrorCode, response.Code)
+					t.Fatalf("wrong code on revision, want %#v, got %#v", tc.RevErrorCode, response)
 				}
 
 				if tc.RevErrorCode == "" {
@@ -1091,6 +1106,9 @@ func TestKeyRevision(t *testing.T) {
 				want := make(map[string]*model.Exposure)
 				revisedReportType := verifyapi.ReportTypeConfirmed
 				revisedTransmissionRisk := verifyapi.TransmissionRiskConfirmedStandard
+				sort.Slice(tc.Publish.Keys, func(i, j int) bool {
+					return tc.Publish.Keys[i].IntervalNumber >= tc.Publish.Keys[j].IntervalNumber
+				})
 				for i, k := range tc.Publish.Keys {
 					expectedKeys[i] = k.Key
 					keyBytes, err := base64util.DecodeString(k.Key)
@@ -1098,19 +1116,20 @@ func TestKeyRevision(t *testing.T) {
 						t.Fatalf("unable to decode exposure key: %v", err)
 					}
 					want[k.Key] = &model.Exposure{
-						ExposureKey:             keyBytes,
-						TransmissionRisk:        verifyapi.TransmissionRiskClinical,
-						AppPackageName:          haName,
-						Regions:                 []string{region},
-						Traveler:                false,
-						IntervalNumber:          k.IntervalNumber,
-						IntervalCount:           k.IntervalCount,
-						LocalProvenance:         true,
-						HealthAuthorityID:       &healthAuthority.ID,
-						ReportType:              verifyapi.ReportTypeClinical,
-						DaysSinceSymptomOnset:   nil,
-						RevisedReportType:       &revisedReportType,
-						RevisedTransmissionRisk: &revisedTransmissionRisk,
+						ExposureKey:                  keyBytes,
+						TransmissionRisk:             verifyapi.TransmissionRiskClinical,
+						AppPackageName:               haName,
+						Regions:                      []string{region},
+						Traveler:                     false,
+						IntervalNumber:               k.IntervalNumber,
+						IntervalCount:                k.IntervalCount,
+						LocalProvenance:              true,
+						HealthAuthorityID:            &healthAuthority.ID,
+						ReportType:                   verifyapi.ReportTypeClinical,
+						DaysSinceSymptomOnset:        proto.Int32(int32(-1 * i)),
+						RevisedReportType:            &revisedReportType,
+						RevisedTransmissionRisk:      &revisedTransmissionRisk,
+						RevisedDaysSinceSymptomOnset: proto.Int32(int32(-1 * i)),
 					}
 					if !keysAreRevisions {
 						ex := want[k.Key]
@@ -1118,6 +1137,7 @@ func TestKeyRevision(t *testing.T) {
 						ex.RevisedReportType = nil
 						ex.TransmissionRisk = *ex.RevisedTransmissionRisk
 						ex.RevisedTransmissionRisk = nil
+						ex.RevisedDaysSinceSymptomOnset = nil
 					}
 				}
 
diff --git a/pkg/timeutils/math_test.go b/pkg/timeutils/math_test.go
new file mode 100644
index 0000000..e7576c0
--- /dev/null
+++ b/pkg/timeutils/math_test.go
@@ -0,0 +1,54 @@
+// 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.
+
+// Package timeutils defines functions to close the gaps present in Golang's
+// default implementation of Time.
+package timeutils
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestSubtractDays(t *testing.T) {
+	day := time.Date(2020, 10, 31, 4, 15, 0, 0, time.UTC)
+
+	cases := []struct {
+		name string
+		days uint
+		want time.Time
+	}{
+		{
+			name: "zero",
+			days: 0,
+			want: day,
+		},
+		{
+			name: "fortnight",
+			days: 14,
+			want: time.Date(2020, 10, 17, 4, 15, 0, 0, time.UTC),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := SubtractDays(day, tc.days)
+			if diff := cmp.Diff(tc.want, got); diff != "" {
+				t.Fatalf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/pkg/timeutils/midnight_test.go b/pkg/timeutils/midnight_test.go
new file mode 100644
index 0000000..77f3078
--- /dev/null
+++ b/pkg/timeutils/midnight_test.go
@@ -0,0 +1,47 @@
+// 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.
+
+// Package timeutils defines functions to close the gaps present in Golang's
+// default implementation of Time.
+package timeutils
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestLocalMidnight(t *testing.T) {
+	day := time.Date(2020, 10, 31, 4, 15, 0, 0, time.Local)
+	want := time.Date(2020, 10, 31, 0, 0, 0, 0, time.Local)
+	got := LocalMidnight(day)
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	got = Midnight(day)
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestUTCMidnight(t *testing.T) {
+	day := time.Date(2020, 10, 31, 4, 15, 0, 0, time.Local)
+	want := time.Date(2020, 10, 31, 0, 0, 0, 0, time.UTC)
+	got := UTCMidnight(day)
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}