Repository: divviup/janus
Pull request #3792: Enforce time_precision for Client Reports and Report Aggregations
Author: login: jcjones
Created: 2025-04-23T22:13:33Z | Merged: 2025-04-24T21:11:14Z

This change enforces that timestamps for client reports and for report aggregations are divisible by the `time_precision` of a task.

NOTE: This does _not_ enforce that `Duration`s of `Interval`s be multiples of `time_precision`, that will be a follow-on PR. (This one is big enough!)

* Adds `validate_precision` to `TimeExt`, which yields `janus_messages::Error::InvalidParameter` if the timestamp of `self` is not divisible by the `time_precision`.
* Adds calls to this `validate_precision` to the datastore routines for `put_report_aggregation` and `put_client_report`, covering aggregations and reports.
* Adds trait `ClockExt` to aggregator's `test_util`, and a method `now_at_batch_interval_start`, which makes all the test copypasta more manageable. 
* Revisits every test that uses report timestamps, _generally_ setting TaskBuilder's `with_time_precision` to `100` seconds (though some I did `10`) and aligning the test as needed. For a couple of tests that had more significant timing logic, I used `time_precision` of `1` second, which made for no other test changes.

I was unsure about putting `ClockExt` into `test_util` while our other `...Ext` traits live elsewhere, but it's really test-only. I'm happy to refactor it elsewhere, let me know.




Diff:
diff --git a/aggregator/src/aggregator/aggregation_job_creator.rs b/aggregator/src/aggregator/aggregation_job_creator.rs
index a8a83a9ea..aaa877c5b 100644
--- a/aggregator/src/aggregator/aggregation_job_creator.rs
+++ b/aggregator/src/aggregator/aggregation_job_creator.rs
@@ -1171,8 +1171,8 @@ mod tests {
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
 
-        let first_report_time = clock.now();
-        let second_report_time = clock.now().add(task.time_precision()).unwrap();
+        let first_report_time = clock.now_aligned_to_precision(task.time_precision());
+        let second_report_time = first_report_time.add(task.time_precision()).unwrap();
         let reports: Arc<Vec<_>> = Arc::new(
             iter::repeat(first_report_time)
                 .take(2 * MAX_AGGREGATION_JOB_SIZE + MIN_AGGREGATION_JOB_SIZE + 1)
@@ -1356,7 +1356,7 @@ mod tests {
             .unwrap(),
         );
 
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let batch_identifier = TimeInterval::to_batch_identifier(&task, &(), &report_time).unwrap();
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
@@ -1573,7 +1573,7 @@ mod tests {
         );
 
         // Create a min-size batch.
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
         let batch_identifier = TimeInterval::to_batch_identifier(&task, &(), &report_time).unwrap();
@@ -1760,7 +1760,7 @@ mod tests {
 
         // Create 2 * MIN_BATCH_SIZE reports. We expect aggregation jobs to be created containing
         // these reports.
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
         let reports: Arc<Vec<_>> = Arc::new(
@@ -1959,7 +1959,7 @@ mod tests {
 
         // Create a small number of reports. No batches or aggregation jobs should be created, and
         // the reports should remain "unaggregated".
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
         let reports: Arc<Vec<_>> = Arc::new(
@@ -2122,7 +2122,7 @@ mod tests {
 
         // Create enough reports to produce two batches, but not enough to meet the minimum number
         // of reports for the second batch.
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
         let reports: Arc<Vec<_>> = Arc::new(
@@ -2385,7 +2385,7 @@ mod tests {
 
         // Create enough reports to produce two batches, and produce a non-maximum size aggregation
         // job with the remainder of the reports.
-        let report_time = clock.now();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
         let reports: Arc<Vec<_>> = Arc::new(
@@ -2656,8 +2656,16 @@ mod tests {
         );
 
         // Create 2 * MIN_BATCH_SIZE reports in two different time buckets.
-        let report_time_1 = clock.now().sub(&batch_time_window_size).unwrap();
-        let report_time_2 = clock.now();
+        let report_time_1 = clock
+            .now()
+            .to_batch_interval_start(&batch_time_window_size)
+            .unwrap()
+            .sub(&batch_time_window_size)
+            .unwrap();
+        let report_time_2 = clock
+            .now()
+            .to_batch_interval_start(&batch_time_window_size)
+            .unwrap();
         let vdaf = Arc::new(Prio3::new_count(2).unwrap());
         let helper_hpke_keypair = HpkeKeypair::test();
 
@@ -2900,7 +2908,7 @@ mod tests {
         // Create MAX_AGGREGATION_JOB_SIZE reports in one batch. This should result in one
         // aggregation job per overlapping collection job for these reports. (and there is one such
         // collection job)
-        let report_time = clock.now().sub(task.time_precision()).unwrap();
+        let report_time = clock.now_aligned_to_precision(task.time_precision());
         let batch_1_reports: Vec<LeaderStoredReport<0, dummy::Vdaf>> =
             iter::repeat_with(|| LeaderStoredReport::new_dummy(*task.id(), report_time))
                 .take(MAX_AGGREGATION_JOB_SIZE)
diff --git a/aggregator/src/aggregator/aggregation_job_init.rs b/aggregator/src/aggregator/aggregation_job_init.rs
index 6d3595863..89f994423 100644
--- a/aggregator/src/aggregator/aggregation_job_init.rs
+++ b/aggregator/src/aggregator/aggregation_job_init.rs
@@ -705,6 +705,8 @@ mod tests {
             AggregationMode::Synchronous,
             vdaf_instance,
         )
+        .with_time_precision(Duration::from_seconds(100))
+        .with_tolerable_clock_skew(Duration::from_seconds(500))
         .with_aggregator_auth_token(auth_token)
         .build();
         let helper_task = task.helper_view().unwrap();
@@ -1003,7 +1005,7 @@ mod tests {
                             random(),
                             test_case
                                 .clock
-                                .now()
+                                .now_aligned_to_precision(test_case.task.time_precision())
                                 .add(test_case.task.tolerable_clock_skew())
                                 .unwrap(),
                             Vec::new(),
@@ -1019,10 +1021,10 @@ mod tests {
                             random(),
                             test_case
                                 .clock
-                                .now()
+                                .now_aligned_to_precision(test_case.task.time_precision())
                                 .add(test_case.task.tolerable_clock_skew())
                                 .unwrap()
-                                .add(&Duration::from_seconds(1))
+                                .add(test_case.task.time_precision())
                                 .unwrap(),
                             Vec::new(),
                         ),
diff --git a/aggregator/src/aggregator/collection_job_tests.rs b/aggregator/src/aggregator/collection_job_tests.rs
index 862c7052c..06cf67225 100644
--- a/aggregator/src/aggregator/collection_job_tests.rs
+++ b/aggregator/src/aggregator/collection_job_tests.rs
@@ -309,7 +309,9 @@ async fn setup_leader_selected_current_batch_collection_job_test_case(
 
     // Fill the datastore with the necessary data so that there are two outstanding batches to be
     // collected.
-    let time = test_case.clock.now();
+    let time = test_case
+        .clock
+        .now_aligned_to_precision(test_case.task.time_precision());
     let batch_id_1 = test_case
         .setup_leader_selected_batch(time, test_case.task.min_batch_size() + 1)
         .await;
diff --git a/aggregator/src/aggregator/garbage_collector.rs b/aggregator/src/aggregator/garbage_collector.rs
index 4a3cc247b..063f0f915 100644
--- a/aggregator/src/aggregator/garbage_collector.rs
+++ b/aggregator/src/aggregator/garbage_collector.rs
@@ -223,13 +223,14 @@ mod tests {
                         VdafInstance::Fake { rounds: 1 },
                     )
                     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                    .with_time_precision(Duration::from_seconds(10))
                     .build()
                     .leader_view()
                     .unwrap();
                     tx.put_aggregator_task(&task).await?;
 
                     // Client report artifacts.
-                    let client_timestamp = clock.now().sub(&Duration::from_seconds(2)).unwrap();
+                    let client_timestamp = clock.now().sub(&Duration::from_seconds(10)).unwrap();
                     let report = LeaderStoredReport::new_dummy(*task.id(), client_timestamp);
                     tx.put_client_report(&report).await.unwrap();
 
@@ -378,13 +379,14 @@ mod tests {
                         VdafInstance::Fake { rounds: 1 },
                     )
                     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                    .with_time_precision(Duration::from_seconds(10))
                     .build()
                     .helper_view()
                     .unwrap();
                     tx.put_aggregator_task(&task).await?;
 
                     // Client report artifacts.
-                    let client_timestamp = clock.now().sub(&Duration::from_seconds(2)).unwrap();
+                    let client_timestamp = clock.now().sub(&Duration::from_seconds(10)).unwrap();
                     let report_share = ReportShare::new(
                         ReportMetadata::new(random(), client_timestamp, Vec::new()),
                         Vec::new(),
@@ -559,6 +561,7 @@ mod tests {
                         VdafInstance::Fake { rounds: 1 },
                     )
                     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                    .with_time_precision(Duration::from_seconds(10))
                     .build()
                     .leader_view()
                     .unwrap();
@@ -569,7 +572,7 @@ mod tests {
                         .now()
                         .sub(&REPORT_EXPIRY_AGE)
                         .unwrap()
-                        .sub(&Duration::from_seconds(2))
+                        .sub(&Duration::from_seconds(10))
                         .unwrap();
                     let report = LeaderStoredReport::new_dummy(*task.id(), client_timestamp);
                     tx.put_client_report(&report).await.unwrap();
@@ -730,6 +733,7 @@ mod tests {
                         AggregationMode::Synchronous,
                         VdafInstance::Fake { rounds: 1 },
                     )
+                    .with_time_precision(Duration::from_seconds(10))
                     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
                     .build()
                     .helper_view()
@@ -741,7 +745,7 @@ mod tests {
                         .now()
                         .sub(&REPORT_EXPIRY_AGE)
                         .unwrap()
-                        .sub(&Duration::from_seconds(2))
+                        .sub(&Duration::from_seconds(10))
                         .unwrap();
                     let report_share = ReportShare::new(
                         ReportMetadata::new(random(), client_timestamp, Vec::new()),
diff --git a/aggregator/src/aggregator/http_handlers/tests/aggregation_job_continue.rs b/aggregator/src/aggregator/http_handlers/tests/aggregation_job_continue.rs
index 2fe615985..96b2ec001 100644
--- a/aggregator/src/aggregator/http_handlers/tests/aggregation_job_continue.rs
+++ b/aggregator/src/aggregator/http_handlers/tests/aggregation_job_continue.rs
@@ -55,6 +55,7 @@ async fn aggregate_continue_sync() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
 
@@ -403,6 +404,7 @@ async fn aggregate_continue_async() {
         AggregationMode::Asynchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
 
@@ -653,6 +655,7 @@ async fn aggregate_continue_accumulate_batch_aggregation() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let aggregation_job_id_0 = random();
@@ -1336,6 +1339,7 @@ async fn aggregate_continue_leader_sends_non_continue_or_finish_transition() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let report_id = random();
@@ -1351,7 +1355,7 @@ async fn aggregate_continue_leader_sends_non_continue_or_finish_transition() {
     let aggregation_job_id = random();
     let report_metadata = ReportMetadata::new(
         ReportId::from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
-        Time::from_seconds_since_epoch(54321),
+        Time::from_seconds_since_epoch(54320),
         Vec::new(),
     );
 
@@ -1450,6 +1454,7 @@ async fn aggregate_continue_prep_step_fails() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let vdaf = dummy::Vdaf::new(2);
@@ -1465,7 +1470,7 @@ async fn aggregate_continue_prep_step_fails() {
     );
     let aggregation_job_id = random();
     let report_metadata =
-        ReportMetadata::new(report_id, Time::from_seconds_since_epoch(54321), Vec::new());
+        ReportMetadata::new(report_id, Time::from_seconds_since_epoch(54320), Vec::new());
     let helper_report_share = generate_helper_report_share::<dummy::Vdaf>(
         *task.id(),
         report_metadata.clone(),
@@ -1631,6 +1636,7 @@ async fn aggregate_continue_unexpected_transition() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let report_id = random();
@@ -1645,7 +1651,7 @@ async fn aggregate_continue_unexpected_transition() {
     );
     let aggregation_job_id = random();
     let report_metadata =
-        ReportMetadata::new(report_id, Time::from_seconds_since_epoch(54321), Vec::new());
+        ReportMetadata::new(report_id, Time::from_seconds_since_epoch(54320), Vec::new());
 
     // Setup datastore.
     datastore
@@ -1742,6 +1748,7 @@ async fn aggregate_continue_out_of_order_transition() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 2 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let report_id_0 = random();
@@ -1756,7 +1763,7 @@ async fn aggregate_continue_out_of_order_transition() {
     );
     let report_metadata_0 = ReportMetadata::new(
         report_id_0,
-        Time::from_seconds_since_epoch(54321),
+        Time::from_seconds_since_epoch(54320),
         Vec::new(),
     );
     let report_id_1 = random();
@@ -1770,7 +1777,7 @@ async fn aggregate_continue_out_of_order_transition() {
     );
     let report_metadata_1 = ReportMetadata::new(
         report_id_1,
-        Time::from_seconds_since_epoch(54321),
+        Time::from_seconds_since_epoch(54320),
         Vec::new(),
     );
     let aggregation_job_id = random();
@@ -1902,12 +1909,13 @@ async fn aggregate_continue_for_non_waiting_aggregation() {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(10))
     .build();
     let helper_task = task.helper_view().unwrap();
     let aggregation_job_id = random();
     let report_metadata = ReportMetadata::new(
         ReportId::from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
-        Time::from_seconds_since_epoch(54321),
+        Time::from_seconds_since_epoch(54320),
         Vec::new(),
     );
 
diff --git a/aggregator/src/aggregator/http_handlers/tests/report.rs b/aggregator/src/aggregator/http_handlers/tests/report.rs
index b5e14f757..707ab2a12 100644
--- a/aggregator/src/aggregator/http_handlers/tests/report.rs
+++ b/aggregator/src/aggregator/http_handlers/tests/report.rs
@@ -17,7 +17,7 @@ use janus_aggregator_core::{
 use janus_core::{
     hpke::{self, HpkeApplicationInfo, HpkeKeypair, Label},
     test_util::{install_test_trace_subscriber, runtime::TestRuntime},
-    time::{Clock, DurationExt, MockClock, TimeExt},
+    time::{Clock, MockClock, TimeExt},
     vdaf::VdafInstance,
 };
 use janus_messages::{
@@ -80,13 +80,18 @@ async fn upload_handler() {
         AggregationMode::Synchronous,
         VdafInstance::Prio3Count,
     )
+    .with_time_precision(Duration::from_seconds(1000))
     .with_report_expiry_age(Some(Duration::from_seconds(REPORT_EXPIRY_AGE)))
     .build();
 
     let leader_task = task.leader_view().unwrap();
     datastore.put_aggregator_task(&leader_task).await.unwrap();
 
-    let report = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     // Upload a report. Do this twice to prove that PUT is idempotent.
     for _ in 0..2 {
@@ -117,7 +122,7 @@ async fn upload_handler() {
     // Verify that new reports using an existing report ID are also accepted as a duplicate.
     let duplicate_id_report = create_report_custom(
         &leader_task,
-        clock.now(),
+        clock.now_aligned_to_precision(task.time_precision()),
         *accepted_report_id,
         &hpke_keypair,
     );
@@ -135,7 +140,7 @@ async fn upload_handler() {
         ReportMetadata::new(
             random(),
             clock
-                .now()
+                .now_aligned_to_precision(task.time_precision())
                 .sub(&Duration::from_seconds(REPORT_EXPIRY_AGE + 30000))
                 .unwrap(),
             report.metadata().public_extensions().to_vec(),
@@ -193,10 +198,10 @@ async fn upload_handler() {
 
     // Reports from the future should be rejected.
     let bad_report_time = clock
-        .now()
-        .add(&Duration::from_minutes(10).unwrap())
-        .unwrap()
-        .add(&Duration::from_seconds(1))
+        .now_aligned_to_precision(task.time_precision())
+        .add(&Duration::from_seconds(
+            task.time_precision().as_seconds() * 2,
+        ))
         .unwrap();
     let bad_report = Report::new(
         ReportMetadata::new(
@@ -229,7 +234,12 @@ async fn upload_handler() {
         AggregationMode::Synchronous,
         VdafInstance::Prio3Count,
     )
-    .with_task_end(Some(clock.now().add(&Duration::from_seconds(60)).unwrap()))
+    .with_task_end(Some(
+        clock
+            .now_aligned_to_precision(task.time_precision())
+            .add(&Duration::from_seconds(60))
+            .unwrap(),
+    ))
     .build();
     let leader_task_end_soon = task_end_soon.leader_view().unwrap();
     datastore
@@ -485,7 +495,11 @@ async fn upload_handler_error_fanout() {
     url.set_port(Some(socket_addr.port())).unwrap();
 
     // Upload one report and wait for it to finish, to prepopulate the aggregator's task cache.
-    let report: Report = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report: Report = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
     let response = client
         .post(url.clone())
         .header("Content-Type", Report::MEDIA_TYPE)
@@ -583,9 +597,17 @@ async fn upload_client_early_disconnect() {
     let leader_task = task.leader_view().unwrap();
     datastore.put_aggregator_task(&leader_task).await.unwrap();
 
-    let report_1 = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report_1 = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
     let encoded_report_1 = report_1.get_encoded().unwrap();
-    let report_2 = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report_2 = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
     let encoded_report_2 = report_2.get_encoded().unwrap();
 
     let stopper = Stopper::new();
diff --git a/aggregator/src/aggregator/upload_tests.rs b/aggregator/src/aggregator/upload_tests.rs
index 7f4107ad7..9bc106699 100644
--- a/aggregator/src/aggregator/upload_tests.rs
+++ b/aggregator/src/aggregator/upload_tests.rs
@@ -113,7 +113,11 @@ async fn upload() {
     .await;
 
     let leader_task = task.leader_view().unwrap();
-    let report = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     aggregator
         .handle_upload(task.id(), &report.get_encoded().unwrap())
@@ -142,7 +146,7 @@ async fn upload() {
     // is stored.
     let mutated_report = create_report_custom(
         &leader_task,
-        clock.now(),
+        clock.now_aligned_to_precision(task.time_precision()),
         *report.metadata().id(),
         &hpke_keypair,
     );
@@ -198,7 +202,14 @@ async fn upload_batch() {
     .await;
 
     let reports: Vec<_> = iter::repeat_with(|| {
-        create_report(&task.leader_view().unwrap(), &hpke_keypair, clock.now())
+        create_report(
+            &task.leader_view().unwrap(),
+            &hpke_keypair,
+            clock
+                .now()
+                .to_batch_interval_start(task.time_precision())
+                .unwrap(),
+        )
     })
     .take(BATCH_SIZE)
     .collect();
@@ -260,7 +271,11 @@ async fn upload_wrong_hpke_config_id() {
     )
     .await;
     let leader_task = task.leader_view().unwrap();
-    let report = create_report(&leader_task, &hpke_keypair, clock.now());
+    let report = create_report(
+        &leader_task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     let unused_hpke_config_id =
         HpkeConfigId::from(u8::from(*hpke_keypair.config().id()).wrapping_add(1));
@@ -327,7 +342,12 @@ async fn upload_report_in_the_future_boundary_condition() {
     let report = create_report(
         &task.leader_view().unwrap(),
         &hpke_keypair,
-        clock.now().add(task.tolerable_clock_skew()).unwrap(),
+        clock
+            .now()
+            .add(task.tolerable_clock_skew())
+            .unwrap()
+            .to_batch_interval_start(task.time_precision())
+            .unwrap(),
     );
 
     aggregator
@@ -435,7 +455,11 @@ async fn upload_report_for_collected_batch() {
         runtime_manager.with_label("aggregator"),
     )
     .await;
-    let report = create_report(&task.leader_view().unwrap(), &hpke_keypair, clock.now());
+    let report = create_report(
+        &task.leader_view().unwrap(),
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     // Insert a collection job for the batch interval including our report.
     let batch_interval = Interval::new(
@@ -534,7 +558,11 @@ async fn upload_report_task_not_started() {
     .unwrap();
     datastore.put_aggregator_task(&task).await.unwrap();
 
-    let report = create_report(&task, &hpke_keypair, clock.now());
+    let report = create_report(
+        &task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     // Try to upload the report, verify that we get the expected error.
     let error = aggregator
@@ -599,8 +627,12 @@ async fn upload_report_task_ended() {
     datastore.put_aggregator_task(&task).await.unwrap();
 
     // Advance the clock to end the task.
-    clock.advance(&Duration::from_seconds(1));
-    let report = create_report(&task, &hpke_keypair, clock.now());
+    clock.advance(task.time_precision());
+    let report = create_report(
+        &task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     // Try to upload the report, verify that we get the expected error.
     let error = aggregator
@@ -664,7 +696,11 @@ async fn upload_report_report_expired() {
     .unwrap();
     datastore.put_aggregator_task(&task).await.unwrap();
 
-    let report = create_report(&task, &hpke_keypair, clock.now());
+    let report = create_report(
+        &task,
+        &hpke_keypair,
+        clock.now_aligned_to_precision(task.time_precision()),
+    );
 
     // Advance the clock to expire the report.
     clock.advance(&Duration::from_seconds(61));
diff --git a/aggregator_core/src/datastore.rs b/aggregator_core/src/datastore.rs
index ab97d283a..8ba2d0bb4 100644
--- a/aggregator_core/src/datastore.rs
+++ b/aggregator_core/src/datastore.rs
@@ -1539,6 +1539,11 @@ WHERE report_aggregations.task_id = $1
             report.leader_private_extensions(),
         )?;
 
+        report
+            .metadata()
+            .time()
+            .validate_precision(&task_info.time_precision)?;
+
         // If there is a conflict, the we upsert the incoming report (excluded) if the existing
         // report is expired (virtually GCed; would be invisible to other queries that retrieve
         // report rows).
@@ -2575,6 +2580,10 @@ WHERE report_aggregations.task_id = $1
             .map(PrepareResp::get_encoded)
             .transpose()?;
 
+        report_aggregation
+            .time()
+            .validate_precision(&task_info.time_precision)?;
+
         // If there is a conflict, the we upsert the incoming report agggregation (excluded) if the
         // existing report aggregation is expired (virtually GCed; would be invisible to other
         // queries that retrieve report rows).
@@ -5676,7 +5685,7 @@ ON CONFLICT (task_id, ord) DO UPDATE SET
         let stmt = self
             .prepare_cached(
                 "-- task_info_for()
-SELECT id, report_expiry_age FROM tasks WHERE tasks.task_id = $1",
+SELECT id, report_expiry_age, time_precision FROM tasks WHERE tasks.task_id = $1",
             )
             .await?;
         let mut rows = self
@@ -5702,9 +5711,11 @@ SELECT id, report_expiry_age FROM tasks WHERE tasks.task_id = $1",
                     .ok_or_else(|| Error::TimeOverflow("overflow computing report expiry age"))
             })
             .transpose()?;
+        let time_precision = Duration::from_seconds(row.get_bigint_and_convert("time_precision")?);
         let task_info = TaskInfo {
             pkey,
             report_expiry_age,
+            time_precision,
         };
 
         // unwrap safety: mutex poisoning
@@ -5722,6 +5733,9 @@ struct TaskInfo {
 
     /// The task's report expiry age, corresponding to the tasks.report_expiry_age column.
     report_expiry_age: Option<chrono::Duration>,
+
+    /// The task's time precision
+    time_precision: Duration,
 }
 
 impl TaskInfo {
diff --git a/aggregator_core/src/datastore/tests.rs b/aggregator_core/src/datastore/tests.rs
index 52f709838..87e0ad59b 100644
--- a/aggregator_core/src/datastore/tests.rs
+++ b/aggregator_core/src/datastore/tests.rs
@@ -507,6 +507,7 @@ async fn roundtrip_report(ephemeral_datastore: EphemeralDatastore) {
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(report_expiry_age))
+    .with_time_precision(Duration::from_seconds(100))
     .build()
     .leader_view()
     .unwrap();
@@ -583,7 +584,7 @@ async fn roundtrip_report(ephemeral_datastore: EphemeralDatastore) {
                     task_id,
                     ReportMetadata::new(
                         report_id,
-                        Time::from_seconds_since_epoch(54321),
+                        Time::from_seconds_since_epoch(54300),
                         Vec::new(),
                     ),
                     (), // public share
@@ -698,6 +699,7 @@ async fn get_unaggregated_client_reports_for_task(ephemeral_datastore: Ephemeral
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(100))
     .build()
     .leader_view()
     .unwrap();
@@ -706,6 +708,7 @@ async fn get_unaggregated_client_reports_for_task(ephemeral_datastore: Ephemeral
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(100))
     .build()
     .leader_view()
     .unwrap();
@@ -717,7 +720,7 @@ async fn get_unaggregated_client_reports_for_task(ephemeral_datastore: Ephemeral
     let expired_report = LeaderStoredReport::new_dummy(
         *task.id(),
         OLDEST_ALLOWED_REPORT_TIMESTAMP
-            .sub(&Duration::from_seconds(1))
+            .sub(&Duration::from_seconds(100))
             .unwrap(),
     );
     let aggregated_report =
@@ -920,6 +923,7 @@ async fn get_unaggregated_client_report_ids_with_agg_param_for_task(
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(100))
     .build()
     .leader_view()
     .unwrap();
@@ -928,18 +932,19 @@ async fn get_unaggregated_client_report_ids_with_agg_param_for_task(
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(100))
     .build()
     .leader_view()
     .unwrap();
 
     let first_unaggregated_report =
-        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12345));
+        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12300));
     let second_unaggregated_report =
-        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12346));
+        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12400));
     let aggregated_report =
-        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12347));
+        LeaderStoredReport::new_dummy(*task.id(), Time::from_seconds_since_epoch(12500));
     let unrelated_report =
-        LeaderStoredReport::new_dummy(*unrelated_task.id(), Time::from_seconds_since_epoch(12348));
+        LeaderStoredReport::new_dummy(*unrelated_task.id(), Time::from_seconds_since_epoch(12600));
 
     // Set up state.
     ds.run_unnamed_tx(|tx| {
@@ -1225,6 +1230,7 @@ async fn count_client_reports_for_interval(ephemeral_datastore: EphemeralDatasto
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -1233,6 +1239,7 @@ async fn count_client_reports_for_interval(ephemeral_datastore: EphemeralDatasto
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -1241,6 +1248,7 @@ async fn count_client_reports_for_interval(ephemeral_datastore: EphemeralDatasto
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -1364,6 +1372,7 @@ async fn count_client_reports_for_batch_id(ephemeral_datastore: EphemeralDatasto
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -1374,6 +1383,7 @@ async fn count_client_reports_for_batch_id(ephemeral_datastore: EphemeralDatasto
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -2585,6 +2595,7 @@ async fn roundtrip_report_aggregation(ephemeral_datastore: EphemeralDatastore) {
             VdafInstance::Fake { rounds: 2 },
         )
         .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+        .with_time_precision(Duration::from_seconds(100))
         .build()
         .view_for_role(role)
         .unwrap();
@@ -2883,6 +2894,7 @@ async fn get_report_aggregations_for_aggregation_job(ephemeral_datastore: Epheme
         VdafInstance::Fake { rounds: 2 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .helper_view()
     .unwrap();
@@ -3056,6 +3068,7 @@ async fn create_report_aggregation_from_client_reports_table(
         VdafInstance::Fake { rounds: 2 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -3259,6 +3272,7 @@ async fn get_collection_job(ephemeral_datastore: EphemeralDatastore) {
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -3482,6 +3496,7 @@ async fn update_collection_jobs(ephemeral_datastore: EphemeralDatastore) {
         AggregationMode::Synchronous,
         VdafInstance::Fake { rounds: 1 },
     )
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -4898,6 +4913,7 @@ async fn roundtrip_batch_aggregation_leader_selected(ephemeral_datastore: Epheme
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+    .with_time_precision(Duration::from_seconds(1))
     .build()
     .leader_view()
     .unwrap();
@@ -4915,6 +4931,7 @@ async fn roundtrip_batch_aggregation_leader_selected(ephemeral_datastore: Epheme
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .leader_view()
                 .unwrap();
@@ -5109,6 +5126,7 @@ async fn roundtrip_aggregate_share_job_time_interval(ephemeral_datastore: Epheme
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
                 .build()
                 .helper_view()
@@ -5282,6 +5300,7 @@ async fn roundtrip_aggregate_share_job_leader_selected(ephemeral_datastore: Ephe
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
                 .build()
                 .helper_view()
@@ -5438,6 +5457,7 @@ async fn roundtrip_outstanding_batch(ephemeral_datastore: EphemeralDatastore) {
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
                 .build()
                 .leader_view()
@@ -5492,6 +5512,7 @@ async fn roundtrip_outstanding_batch(ephemeral_datastore: EphemeralDatastore) {
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
                 .build()
                 .leader_view()
@@ -5809,6 +5830,7 @@ async fn delete_expired_client_reports(ephemeral_datastore: EphemeralDatastore)
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .with_report_expiry_age(Some(report_expiry_age))
                 .build()
                 .leader_view()
@@ -5818,6 +5840,7 @@ async fn delete_expired_client_reports(ephemeral_datastore: EphemeralDatastore)
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .leader_view()
                 .unwrap();
@@ -5912,6 +5935,7 @@ async fn delete_expired_client_reports_noop(ephemeral_datastore: EphemeralDatast
                     AggregationMode::Synchronous,
                     VdafInstance::Fake { rounds: 1 },
                 )
+                .with_time_precision(Duration::from_seconds(100))
                 .with_report_expiry_age(None)
                 .build()
                 .leader_view()
@@ -5921,7 +5945,7 @@ async fn delete_expired_client_reports_noop(ephemeral_datastore: EphemeralDatast
                 let old_report = LeaderStoredReport::new_dummy(
                     *task.id(),
                     OLDEST_ALLOWED_REPORT_TIMESTAMP
-                        .sub(&Duration::from_seconds(1))
+                        .sub(task.time_precision())
                         .unwrap(),
                 );
                 let new_report =
@@ -6060,6 +6084,7 @@ async fn delete_expired_aggregation_artifacts(ephemeral_datastore: EphemeralData
                     VdafInstance::Fake { rounds: 1 },
                 )
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .leader_view()
                 .unwrap();
@@ -6069,6 +6094,7 @@ async fn delete_expired_aggregation_artifacts(ephemeral_datastore: EphemeralData
                     VdafInstance::Fake { rounds: 1 },
                 )
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .helper_view()
                 .unwrap();
@@ -6080,6 +6106,7 @@ async fn delete_expired_aggregation_artifacts(ephemeral_datastore: EphemeralData
                     VdafInstance::Fake { rounds: 1 },
                 )
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .helper_view()
                 .unwrap();
@@ -6091,6 +6118,7 @@ async fn delete_expired_aggregation_artifacts(ephemeral_datastore: EphemeralData
                     VdafInstance::Fake { rounds: 1 },
                 )
                 .with_report_expiry_age(Some(REPORT_EXPIRY_AGE))
+                .with_time_precision(Duration::from_seconds(1))
                 .build()
                 .helper_view()
                 .unwrap();
@@ -7811,6 +7839,7 @@ async fn accept_write_expired_report(ephemeral_datastore: EphemeralDatastore) {
         VdafInstance::Fake { rounds: 1 },
     )
     .with_report_expiry_age(Some(report_expiry_age))
+    .with_time_precision(Duration::from_seconds(10))
     .build()
     .leader_view()
     .unwrap();
diff --git a/aggregator_core/src/task.rs b/aggregator_core/src/task.rs
index 36f773acc..5287f85bb 100644
--- a/aggregator_core/src/task.rs
+++ b/aggregator_core/src/task.rs
@@ -1130,9 +1130,9 @@ pub mod test_util {
                 None,
                 None,
                 None,
-                1,
-                Duration::from_hours(8).unwrap(),
-                Duration::from_minutes(10).unwrap(),
+                /* Min batch size */ 1,
+                /* Time precision */ Duration::from_hours(8).unwrap(),
+                /* Tolerable clock skew */ Duration::from_minutes(10).unwrap(),
                 /* Collector HPKE keypair */ HpkeKeypair::test(),
                 /* Aggregator auth token */ random(),
                 /* Collector auth token */ random(),
diff --git a/core/src/time.rs b/core/src/time.rs
index 6fdffbed0..43c52d0c0 100644
--- a/core/src/time.rs
+++ b/core/src/time.rs
@@ -11,6 +11,20 @@ use std::{
 pub trait Clock: 'static + Clone + Debug + Sync + Send {
     /// Get the current time.
     fn now(&self) -> Time;
+
+    /// Get the current time, truncated to the provided time precision. The answer will
+    /// be between now and now()-time_precision.
+    #[cfg(feature = "test-util")]
+    fn now_aligned_to_precision(&self, time_precision: &Duration) -> Time {
+        let seconds = self.now().as_seconds_since_epoch();
+        // These unwraps are unsafe, and must only be used for tests.
+        let rem = seconds.checked_rem(time_precision.as_seconds()).unwrap();
+
+        seconds
+            .checked_sub(rem)
+            .map(Time::from_seconds_since_epoch)
+            .unwrap()
+    }
 }
 
 /// A real clock returns the current time relative to the Unix epoch.
@@ -184,6 +198,9 @@ pub trait TimeExt: Sized {
         time_precision: &Duration,
     ) -> Result<Self, janus_messages::Error>;
 
+    /// Confirm that the time is a multiple of the task time precision.
+    fn validate_precision(self, time_precision: &Duration) -> Result<Self, janus_messages::Error>;
+
     /// Convert this [`Time`] into a [`NaiveDateTime`], representing an instant in the UTC timezone.
     fn as_naive_date_time(&self) -> Result<NaiveDateTime, Error>;
 
@@ -230,6 +247,24 @@ impl TimeExt for Time {
             ))
     }
 
+    fn validate_precision(self, time_precision: &Duration) -> Result<Self, janus_messages::Error> {
+        let is_multiple_of_time_precision = self
+            .as_seconds_since_epoch()
+            .checked_rem(time_precision.as_seconds())
+            .ok_or(janus_messages::Error::IllegalTimeArithmetic(
+                "remainder cannot be zero",
+            ))
+            .is_ok_and(|rem| rem == 0);
+
+        if is_multiple_of_time_precision {
+            Ok(self)
+        } else {
+            Err(janus_messages::Error::InvalidParameter(
+                "timestamp is not a multiple of the time precision",
+            ))
+        }
+    }
+
     fn as_naive_date_time(&self) -> Result<NaiveDateTime, Error> {
         DateTime::<Utc>::from_timestamp(
             self.as_seconds_since_epoch()
@@ -355,7 +390,7 @@ impl IntervalExt for Interval {
 
 #[cfg(test)]
 mod tests {
-    use crate::time::{DurationExt, IntervalExt};
+    use crate::time::{Clock, DurationExt, IntervalExt, MockClock, TimeExt};
     use janus_messages::{Duration, Interval, Time};
 
     #[test]
@@ -470,4 +505,43 @@ mod tests {
             }
         }
     }
+
+    #[test]
+    fn validate_time_precision() {
+        for (label, timestamp, timestamp_precision, expected) in [
+            ("aligned", 1533415320, 60, true),
+            ("off by 1", 1533415321, 60, false),
+            ("aligned large", 1533414000, 6000, true),
+            ("off by 100", 1533414100, 6000, false),
+            ("zero time precision", 1533414000, 0, false),
+            ("zero time on a zero timestamp", 0, 0, false),
+        ] {
+            let time = Time::from_seconds_since_epoch(timestamp);
+            let precision = Duration::from_seconds(timestamp_precision);
+
+            let result = time.validate_precision(&precision);
+            match expected {
+                true => assert!(result.is_ok(), "{label}"),
+                false => assert!(result.is_err(), "{label}"),
+            }
+        }
+    }
+
+    #[test]
+    fn now_aligned_to_precision() {
+        for (label, timestamp, timestamp_precision, expected) in [
+            ("aligned", 1533415320, 60, 1533415320),
+            ("off by 1", 1533415321, 60, 1533415320),
+            ("aligned large", 1533414000, 6000, 1533414000),
+            ("off by 100", 1533414100, 6000, 1533414000),
+        ] {
+            let clock = MockClock::new(Time::from_seconds_since_epoch(timestamp));
+            let precision = Duration::from_seconds(timestamp_precision);
+
+            let result = clock
+                .now_aligned_to_precision(&precision)
+                .as_seconds_since_epoch();
+            assert_eq!(expected, result, "{label}");
+        }
+    }
 }
diff --git a/integration_tests/tests/integration/common.rs b/integration_tests/tests/integration/common.rs
index 2d1436077..416f6d696 100644
--- a/integration_tests/tests/integration/common.rs
+++ b/integration_tests/tests/integration/common.rs
@@ -170,8 +170,12 @@ pub async fn submit_measurements_and_verify_aggregate_generic<V>(
     V: vdaf::Client<16> + vdaf::Collector + InteropClientEncoding,
     V::AggregateResult: PartialEq,
 {
-    let before_timestamp =
-        submit_measurements_generic(&test_case.measurements, client_implementation).await;
+    let before_timestamp = submit_measurements_generic(
+        &test_case.measurements,
+        client_implementation,
+        &task_parameters.time_precision,
+    )
+    .await;
 
     verify_aggregate_generic(
         task_parameters,
@@ -186,6 +190,7 @@ pub async fn submit_measurements_and_verify_aggregate_generic<V>(
 pub async fn submit_measurements_generic<V>(
     measurements: &[V::Measurement],
     client_implementation: &ClientImplementation<V>,
+    time_precision: &Duration,
 ) -> Time
 where
     V: vdaf::Client<16> + vdaf::Collector + InteropClientEncoding,
@@ -196,7 +201,10 @@ where
     let time = RealClock::default().now();
     for measurement in measurements.iter() {
         client_implementation
-            .upload(measurement, time)
+            .upload(
+                measurement,
+                time.to_batch_interval_start(time_precision).unwrap(),
+            )
             .await
             .unwrap();
     }
diff --git a/integration_tests/tests/integration/in_cluster.rs b/integration_tests/tests/integration/in_cluster.rs
index 2b24b041c..ab36d0cb4 100644
--- a/integration_tests/tests/integration/in_cluster.rs
+++ b/integration_tests/tests/integration/in_cluster.rs
@@ -965,7 +965,12 @@ async fn in_cluster_histogram_dp_noise() {
         )
         .await
         .unwrap();
-    let before_timestamp = submit_measurements_generic(&measurements, &client_implementation).await;
+    let before_timestamp = submit_measurements_generic(
+        &measurements,
+        &client_implementation,
+        &janus_pair.task_parameters.time_precision,
+    )
+    .await;
     let (report_count, aggregate_result) = collect_aggregate_result_generic(
         &janus_pair.task_parameters,
         janus_pair.leader.port(),
@@ -1033,7 +1038,12 @@ async fn in_cluster_sumvec_dp_noise() {
         )
         .await
         .unwrap();
-    let before_timestamp = submit_measurements_generic(&measurements, &client_implementation).await;
+    let before_timestamp = submit_measurements_generic(
+        &measurements,
+        &client_implementation,
+        &janus_pair.task_parameters.time_precision,
+    )
+    .await;
     let (report_count, aggregate_result) = collect_aggregate_result_generic(
         &janus_pair.task_parameters,
         janus_pair.leader.port(),
diff --git a/integration_tests/tests/integration/janus.rs b/integration_tests/tests/integration/janus.rs
index 71b21cfb3..131d42fb5 100644
--- a/integration_tests/tests/integration/janus.rs
+++ b/integration_tests/tests/integration/janus.rs
@@ -544,7 +544,12 @@ async fn janus_in_process_histogram_dp_noise() {
         )
         .await
         .unwrap();
-    let before_timestamp = submit_measurements_generic(&measurements, &client_implementation).await;
+    let before_timestamp = submit_measurements_generic(
+        &measurements,
+        &client_implementation,
+        &janus_pair.task_parameters.time_precision,
+    )
+    .await;
     let (report_count, aggregate_result) = collect_aggregate_result_generic(
         &janus_pair.task_parameters,
         janus_pair.leader.port(),
@@ -610,7 +615,12 @@ async fn janus_in_process_sumvec_dp_noise() {
         )
         .await
         .unwrap();
-    let before_timestamp = submit_measurements_generic(&measurements, &client_implementation).await;
+    let before_timestamp = submit_measurements_generic(
+        &measurements,
+        &client_implementation,
+        &janus_pair.task_parameters.time_precision,
+    )
+    .await;
     let (report_count, aggregate_result) = collect_aggregate_result_generic(
         &janus_pair.task_parameters,
         janus_pair.leader.port(),
diff --git a/integration_tests/tests/integration/simulation/bad_client.rs b/integration_tests/tests/integration/simulation/bad_client.rs
index 68bff4070..82de13ebd 100644
--- a/integration_tests/tests/integration/simulation/bad_client.rs
+++ b/integration_tests/tests/integration/simulation/bad_client.rs
@@ -435,7 +435,7 @@ async fn bad_client_report_validity() {
         .with_prebound_server(server)
         .spawn(handler);
 
-    let report_time = clock.now();
+    let report_time = clock.now_aligned_to_precision(task.time_precision());
     upload_replay_report(0, &task, &vdaf, &report_time, &http_client)
         .await
         .unwrap();


Reviews:
user: login: tgeoghegan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:08:41Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:26:59Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:42:52Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:53:54Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T18:11:28Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T18:11:36Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T19:25:08Z
user: login: tgeoghegan
body: Most excellent
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:04:28Z
user: login: jcjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:11:12Z

Review comments:
diff_hunk: @@ -184,6 +184,9 @@ pub trait TimeExt: Sized {
         time_precision: &Duration,
     ) -> Result<Self, janus_messages::Error>;
 
+    /// Confirm that the time is a multiple of the task time precision.
+    fn validate_precision(&self, time_precision: &Duration) -> Result<Self, janus_messages::Error>;
path: core/src/time.rs
user: login: tgeoghegan
body: Since this returns a `Self` value  iff `self` is valid, should it take `self` by value? That way, it's impossible to use an invalid `Time` if this method fails because it will have been moved into the method.

created_at: 2025-04-24T16:54:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
path: aggregator/src/aggregator/test_util.rs
user: login: tgeoghegan
body: The reason we do extension traits on other time related types has to do with the layering of the crates. In particular, `janus_messages` gets used by Firefox's DAP client (I think; or at least we want that to be possible) so we want it to have minimal dependencies, and for that reason it doesn't pull in `chrono`. Instead we do that in `janus_core` and up.

However it seems like your objective is to have some functionality on `Clock`s that's only available in test configurations. Sticking this in `test_util` achieves that, but it could be simpler to stick this method in `janus_core::time::Clock` with `#[cfg(test)]` or gate it on feature `test_util`. Then at least all the clock logic is in one place. However I don't object to how you've done it here.
created_at: 2025-04-24T17:00:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
+    fn now_at_batch_interval_start(&self, time_precision: &janusDuration) -> Time;
path: aggregator/src/aggregator/test_util.rs
user: login: tgeoghegan
body: I find the name of this method confusing, but I think that's due in part to the underlying `to_batch_interval_start` being badly named, too. That method is documented as "Compute the start of the batch interval containing this Time, given the task time precision" but I don't think that's right: what it does is round the time down to the provided precision. We must have changed what it does along the way without renaming it.

In any case, I think we could name this `ClockExt` method better. Is `ClockExt::now_aligned_to_precision` correct in your view?
created_at: 2025-04-24T17:04:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -184,6 +184,9 @@ pub trait TimeExt: Sized {
         time_precision: &Duration,
     ) -> Result<Self, janus_messages::Error>;
 
+    /// Confirm that the time is a multiple of the task time precision.
+    fn validate_precision(&self, time_precision: &Duration) -> Result<Self, janus_messages::Error>;
path: core/src/time.rs
user: login: jcjones
body: That's a cool idea!
created_at: 2025-04-24T17:26:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
+    fn now_at_batch_interval_start(&self, time_precision: &janusDuration) -> Time;
path: aggregator/src/aggregator/test_util.rs
user: login: jcjones
body: I think that's a great name.
created_at: 2025-04-24T17:42:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
path: aggregator/src/aggregator/test_util.rs
user: login: jcjones
body: Putting it into `Clock` seems fine, I'd need to _not_ use `to_batch_interval_start` and check the remainder directly.
created_at: 2025-04-24T17:53:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
+    fn now_at_batch_interval_start(&self, time_precision: &janusDuration) -> Time;
path: aggregator/src/aggregator/test_util.rs
user: login: jcjones
body: Updated in 693c69b2868b38242eca1584aa5c1e2ec56004d4
created_at: 2025-04-24T18:11:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -184,6 +184,9 @@ pub trait TimeExt: Sized {
         time_precision: &Duration,
     ) -> Result<Self, janus_messages::Error>;
 
+    /// Confirm that the time is a multiple of the task time precision.
+    fn validate_precision(&self, time_precision: &Duration) -> Result<Self, janus_messages::Error>;
path: core/src/time.rs
user: login: jcjones
body: Done in 693c69b2868b38242eca1584aa5c1e2ec56004d4
created_at: 2025-04-24T18:11:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,3 +203,15 @@ pub async fn assert_task_aggregation_counter(
         sleep(Duration::from_millis(100)).await;
     }
 }
+
+/// Extension methods on [`Clock`].
+pub trait ClockExt: Sized {
path: aggregator/src/aggregator/test_util.rs
user: login: jcjones
body: I think 23805016 works!
created_at: 2025-04-24T19:25:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5722,6 +5733,9 @@ struct TaskInfo {
 
     /// The task's report expiry age, corresponding to the tasks.report_expiry_age column.
     report_expiry_age: Option<chrono::Duration>,
+
+    /// The task's time precision
+    time_precision: Duration,
path: aggregator_core/src/datastore.rs
user: login: tgeoghegan
body: Hmm, mixing `chrono::Duration` and `janus_messages::Duration` in a struct is a little smelly. But I don't think it's worth fixing here. We have an issue backlogged for not using on-the-wire time types in business logic.
created_at: 2025-04-24T20:04:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5722,6 +5733,9 @@ struct TaskInfo {
 
     /// The task's report expiry age, corresponding to the tasks.report_expiry_age column.
     report_expiry_age: Option<chrono::Duration>,
+
+    /// The task's time precision
+    time_precision: Duration,
path: aggregator_core/src/datastore.rs
user: login: jcjones
body: I thought about adding methods to chrono::Duration, but yeah.
created_at: 2025-04-24T20:11:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: jcjones
created_at: 2025-04-24T16:45:50Z
body: It's also worth pointing out that I put the validation of timestamps at the datastore layer, because the information needed was available there. It's feasible to move that up, it just increases the number of times we call the (admittedly already caching) task lookup datastore method.
author_association: CONTRIBUTOR
user: login: tgeoghegan
created_at: 2025-04-24T17:09:17Z
body: Thanks for slogging through all the time stuff in the tests. I know from experience that can be a real chore.
author_association: CONTRIBUTOR