diff --git a/common/persistence/pom.xml b/common/persistence/pom.xml
--- a/common/persistence/pom.xml
+++ b/common/persistence/pom.xml
@@ -78,7 +78,7 @@
     <dependency>
       <groupId>com.h2database</groupId>
       <artifactId>h2</artifactId>
-      <version>1.4.199</version>
+      <version>1.4.200</version>
       <scope>runtime</scope>
     </dependency>
     <dependency>
diff --git a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/domain/DiagnosisKey.java b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/domain/DiagnosisKey.java
--- a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/domain/DiagnosisKey.java
+++ b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/domain/DiagnosisKey.java
@@ -29,9 +29,8 @@
 import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
+import javax.persistence.Column;
 import javax.persistence.Entity;
-import javax.persistence.GeneratedValue;
-import javax.persistence.GenerationType;
 import javax.persistence.Id;
 import javax.persistence.Table;
 import javax.validation.ConstraintViolation;
@@ -57,10 +56,8 @@ public class DiagnosisKey {
   private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();

   @Id
-  @GeneratedValue(strategy = GenerationType.IDENTITY)
-  private Long id;
-
   @Size(min = 16, max = 16, message = "Key data must be a byte array of length 16.")
+  @Column(unique = true)
   private byte[] keyData;

   @ValidRollingStartIntervalNumber
@@ -100,10 +97,6 @@ public static Builder builder() {
     return new DiagnosisKeyBuilder();
   }

-  public Long getId() {
-    return id;
-  }
-
   /**
    * Returns the diagnosis key.
    */
@@ -189,14 +182,13 @@ public boolean equals(Object o) {
         && rollingPeriod == that.rollingPeriod
         && transmissionRiskLevel == that.transmissionRiskLevel
         && submissionTimestamp == that.submissionTimestamp
-        && Objects.equals(id, that.id)
         && Arrays.equals(keyData, that.keyData);
   }

   @Override
   public int hashCode() {
     int result = Objects
-        .hash(id, rollingStartIntervalNumber, rollingPeriod, transmissionRiskLevel, submissionTimestamp);
+        .hash(rollingStartIntervalNumber, rollingPeriod, transmissionRiskLevel, submissionTimestamp);
     result = 31 * result + Arrays.hashCode(keyData);
     return result;
   }
diff --git a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/repository/DiagnosisKeyRepository.java b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/repository/DiagnosisKeyRepository.java
--- a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/repository/DiagnosisKeyRepository.java
+++ b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/repository/DiagnosisKeyRepository.java
@@ -22,6 +22,8 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;

 @Repository
@@ -33,4 +35,22 @@ public interface DiagnosisKeyRepository extends JpaRepository<DiagnosisKey, Long
    * @param submissionTimestamp the submission timestamp up to which entries will be deleted.
    */
   void deleteBySubmissionTimestampIsLessThanEqual(long submissionTimestamp);
+
+  /**
+   * Attempts to write the specified diagnosis key information into the database. If a row with the specified key data
+   * already exists, no data is inserted.
+   *
+   * @param keyData                    The key data of the diagnosis key.
+   * @param rollingStartIntervalNumber The rolling start interval number of the diagnosis key.
+   * @param rollingPeriod              The rolling period of the diagnosis key.
+   * @param submissionTimestamp        The submission timestamp of the diagnosis key.
+   * @param transmissionRisk           The transmission risk level of the diagnosis key.
+   */
+  @Modifying
+  @Query(nativeQuery = true, value =
+      "INSERT INTO diagnosis_key"
+          + "(key_data, rolling_start_interval_number, rolling_period, submission_timestamp, transmission_risk_level)"
+          + " VALUES(?, ?, ?, ?, ?) ON CONFLICT DO NOTHING;")
+  void saveDoNothingOnConflict(byte[] keyData, int rollingStartIntervalNumber, int rollingPeriod,
+      long submissionTimestamp, int transmissionRisk);
 }
diff --git a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
--- a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
+++ b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
@@ -50,13 +50,19 @@ public DiagnosisKeyService(DiagnosisKeyRepository keyRepository) {
   }

   /**
-   * Persists the specified collection of {@link DiagnosisKey} instances.
+   * Persists the specified collection of {@link DiagnosisKey} instances. If the key data of a particular diagnosis key
+   * already exists in the database, this diagnosis key is not persisted.
    *
    * @param diagnosisKeys must not contain {@literal null}.
    * @throws IllegalArgumentException in case the given collection contains {@literal null}.
    */
+  @Transactional
   public void saveDiagnosisKeys(Collection<DiagnosisKey> diagnosisKeys) {
-    keyRepository.saveAll(diagnosisKeys);
+    for (DiagnosisKey diagnosisKey : diagnosisKeys) {
+      keyRepository.saveDoNothingOnConflict(
+          diagnosisKey.getKeyData(), diagnosisKey.getRollingStartIntervalNumber(), diagnosisKey.getRollingPeriod(),
+          diagnosisKey.getSubmissionTimestamp(), diagnosisKey.getTransmissionRiskLevel());
+    }
   }

   /**
diff --git a/common/persistence/src/main/resources/db/migration/h2/V3__makeKeyDataPrimaryKey.sql b/common/persistence/src/main/resources/db/migration/h2/V3__makeKeyDataPrimaryKey.sql
new file mode 100644
--- /dev/null
+++ b/common/persistence/src/main/resources/db/migration/h2/V3__makeKeyDataPrimaryKey.sql
@@ -0,0 +1,3 @@
+ALTER TABLE diagnosis_key DROP COLUMN id;
+ALTER TABLE diagnosis_key ALTER COLUMN key_data SET NOT NULL;
+ALTER TABLE diagnosis_key ADD PRIMARY KEY (key_data);
\ No newline at end of file
diff --git a/common/persistence/src/main/resources/db/migration/postgres/V3__makeKeyDataPrimaryKey.sql b/common/persistence/src/main/resources/db/migration/postgres/V3__makeKeyDataPrimaryKey.sql
new file mode 100644
--- /dev/null
+++ b/common/persistence/src/main/resources/db/migration/postgres/V3__makeKeyDataPrimaryKey.sql
@@ -0,0 +1,3 @@
+ALTER TABLE diagnosis_key DROP COLUMN id;
+ALTER TABLE diagnosis_key ALTER COLUMN key_data SET NOT NULL;
+ALTER TABLE diagnosis_key ADD PRIMARY KEY (key_data);
\ No newline at end of file

diff --git a/common/persistence/src/test/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyServiceTest.java b/common/persistence/src/test/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyServiceTest.java
--- a/common/persistence/src/test/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyServiceTest.java
+++ b/common/persistence/src/test/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyServiceTest.java
@@ -33,6 +33,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Random;
 import org.assertj.core.util.Lists;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
@@ -149,9 +150,34 @@ void testNoPersistOnValidationError() {
     assertDiagnosisKeysEqual(Lists.emptyList(), actKeys);
   }

+  @Test
+  void shouldNotUpdateExistingKey() {
+    var keyData = "1234567890123456";
+    var keys = List.of(DiagnosisKey.builder()
+            .withKeyData(keyData.getBytes())
+            .withRollingStartIntervalNumber(600)
+            .withTransmissionRiskLevel(2)
+            .withSubmissionTimestamp(0L).build(),
+        DiagnosisKey.builder()
+            .withKeyData(keyData.getBytes())
+            .withRollingStartIntervalNumber(600)
+            .withTransmissionRiskLevel(3)
+            .withSubmissionTimestamp(0L).build());
+
+    diagnosisKeyService.saveDiagnosisKeys(keys);
+
+    var actKeys = diagnosisKeyService.getDiagnosisKeys();
+
+    assertThat(actKeys.size()).isEqualTo(1);
+    assertThat(actKeys.iterator().next().getTransmissionRiskLevel()).isEqualTo(2);
+  }
+
   public static DiagnosisKey buildDiagnosisKeyForSubmissionTimestamp(long submissionTimeStamp) {
+    byte[] randomBytes = new byte[16];
+    Random random = new Random(submissionTimeStamp);
+    random.nextBytes(randomBytes);
     return DiagnosisKey.builder()
-        .withKeyData(new byte[16])
+        .withKeyData(randomBytes)
         .withRollingStartIntervalNumber(600)
         .withTransmissionRiskLevel(2)
         .withSubmissionTimestamp(submissionTimeStamp).build();
diff --git a/common/persistence/src/test/resources/application.yaml b/common/persistence/src/test/resources/application.yaml
--- a/common/persistence/src/test/resources/application.yaml
+++ b/common/persistence/src/test/resources/application.yaml
@@ -1,5 +1,8 @@
 ---
 spring:
+  test:
+    database:
+      replace: none
   flyway:
     enabled: false
   jpa:
@@ -8,6 +11,10 @@ spring:
     properties:
       hibernate:
         show_sql: false
+  datasource:
+    url: jdbc:h2:mem:test;MODE=PostgreSQL
+    driverClassName: org.h2.Driver
+    platform: h2
   main:
     banner-mode: off
 logging:
diff --git a/services/submission/src/test/resources/application.yaml b/services/submission/src/test/resources/application.yaml
--- a/services/submission/src/test/resources/application.yaml
+++ b/services/submission/src/test/resources/application.yaml
@@ -11,7 +11,13 @@ spring:
     enabled: true
     # default case is H2 - value will be overwritten by profile cloud or postgres
     locations: classpath:db/migration/h2
-
+  datasource:
+    url: jdbc:h2:mem:test;MODE=PostgreSQL
+    driverClassName: org.h2.Driver
+    platform: h2
+  test:
+    database:
+      replace: none
   jpa:
     hibernate:
       ddl-auto: validate

Ensure uniqueness of diagnosis keys
## Current Implementation
The uniqueness of diagnosis keys is neither guaranteed by the database, nor by validation logic.

## Suggested Enhancement
Ensure that diagnosis keys in the database are unique (there are no duplicates).

What needs to be decided:
- [x] Is it sufficient to guarantee the uniqueness of the diagnosis key or should the uniqueness of diagnosis key + submission timestamp (+maybe even more fields) be guaranteed?
- [x] Should the duplicate check happen on the database (`UNIQUE` constraint) or in the server logic (e.g. during the validation of submitted keys)?
- [x] If the check happens on the database: Should the `id` column be removed and replaced with the diagnosis key (+submission timestamp) as (composite) primary key?
- [x] How should the server react when a duplicate diagnosis key is submitted (update, error, etc.)?

**Edit:** Decisions are below.

Decisions made by @christian-kirschnick:
- Ensuring the uniqueness of the diagnosis key is sufficient
- Duplicate check should happen on the database
- `id` column should be removed
- `key_data` column should become primary key
- If we encounter problems with the current type (`bytea`) of `key_data` as primary key, then change the type to a base64 encoded String
- When duplicate keys are submitted, the server should just ignore them (no update, no error).
@pithumke Was referenced back to here from a different issue, What would be the value of base64 encoding the UUID? it'll just take up even more space than even using char(32). there is a UUID type already in pg that'll give better seek performance and cost 16 bytes per entry instead of 32

About the risk of collision itself, with UUIDv4 that is quite unlikely even with billions of records, However if you don't want to suffer the occasional exception on the server side you can just add `ON CONFLICT DO NOTHING` to swallow the error
Hi @oiime, thank you for the remarks! Good point with the UUID type, I'll bring it up in the team discussions and report back. However, please note that, to my knowledge, the type of the diagnosis keys is not explicitly stated as UUID by the Google/Apple spec, but only as "Data" (see [here](https://developer.apple.com/documentation/exposurenotification/entemporaryexposurekey/3583679-keydata)) containing 16 bytes (see [here](https://www.blog.google/documents/60/Exposure_Notification_-_Cryptography_Specification_v1.1.pdf)). This can, in theory, be mapped to a UUID, but I would like to investigate if the PG UUID type implies any restrictions. For example, UUIDv4 reserves 6 of the 128 bits for version information, leaving only 122 bits for the payload (see [here](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))) - and this would cause major problems for us.

Regarding the collisions: we currently also don't see this as a major risk. Our current strategy, as you proposed, is to simply ignore duplicates.
@pithumke Well, you're the one defining both the client and the server side, the EN API is agnostic to its content to allow that freedom of implementation, so you can define that key to be whatever you want it to be, currently you are validating it as UUID in the server and the test case generates a UUIDv4. the UUID data type follows RFC 4122 as you would expect and would naturally cover UUIDv4. All UUID versions reserve a place for version information. _please_ do not try to come up with your own UUID variant if that is what is suggested here
Hi @oiime, thank you for your comment.

> Well, you're the one defining both the client and the server side, the EN API is agnostic to its content to allow that freedom of implementation, so you can define that key to be whatever you want it to be

Yes, we do specify and develop both the CWA server and client. However, we neither specify nor develop the on-device APIs - that is done by Apple and Google. Inside those on-device APIs is where the keys are being generated and that does not give us much freedom regarding data types on our end. In the specification it is stated, that the key data consists of 16 bytes (= 128 bits). Since the use of UUID would restrict that to 122 bits, we can not use it as the column type on postgres.

> currently you are validating it as UUID in the server and the test case generates a UUIDv4

I am not aware of any UUID handling in our current code base, besides for TANs (which are not stored on the server). Can you please link which specific code you are referring to?

> please do not try to come up with your own UUID variant if that is what is suggested here

I didn't mean to suggest that and we don't want to come up with an own UUID variant. This is why we decided to go for a base64 encoded string for now (if bytea turns out to cause problems).
@pithumke

> However, we neither specify nor develop the on-device API

I guess I rely on looking at the API itself, perhaps apple or google or both have a specific implementation they mandate or suggest, if that is the case then using UUID is wrong anyway

> I am not aware of any UUID handling in our current code base, besides for TANs

I am likely conflating the two, I have only skimmed through the code and what I recalled was the test generating UUIDv4

> I didn't mean to suggest that and we don't want to come up with an own UUID variant. This is why we decided to go for a base64 encoded string for now (if bytea turns out to cause problems).

base64 seems like an unnecessary step that'll just take up more space and computation, you do it to avoid just storing it in a char(16) as-is? though I don't see why bytea wouldn't work as a PK, especially as you'd never update it


diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ Profile          | Effect
 -----------------|-------------
 `dev`            | Turns the log level to `DEBUG`.
 `cloud`          | Removes default values for the `datasource` and `objectstore` configurations.
-`demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten.
+`demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten. This profile also turns off the expiry policy (Keys must be expired for at least 2 hours before distribution) and the shifting policy (there must be at least 140 keys in a distribution).
 `testdata`       | Causes test data to be inserted into the database before each distribution run. By default, around 1000 random diagnosis keys will be generated per hour. If there are no diagnosis keys in the database yet, random keys will be generated for every hour from the beginning of the retention period (14 days ago at 00:00 UTC) until one hour before the present hour. If there are already keys in the database, the random keys will be generated for every hour from the latest diagnosis key in the database (by submission timestamp) until one hour before the present hour (or none at all, if the latest diagnosis key in the database was submitted one hour ago or later).
 `signature-dev`  | Sets the app package ID in the export packages' signature info to `de.rki.coronawarnapp-dev` so that test certificates (instead of production certificates) will be used for client-side validation.
 `signature-prod` | Provides production app package IDs for the signature info
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.common.persistence.service.DiagnosisKeyService;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.DiagnosisKeysDirectory;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
@@ -29,6 +30,7 @@
 import java.util.Collection;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;

 /**
@@ -40,6 +42,8 @@ public class DiagnosisKeysStructureProvider {
   private static final Logger logger = LoggerFactory
       .getLogger(DiagnosisKeysStructureProvider.class);

+  @Autowired
+  private DiagnosisKeyBundler diagnosisKeyBundler;
   private final DiagnosisKeyService diagnosisKeyService;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
@@ -56,11 +60,13 @@ public class DiagnosisKeysStructureProvider {

   /**
    * Get directory for diagnosis keys from database.
+   *
    * @return the directory
    */
   public Directory<WritableOnDisk> getDiagnosisKeys() {
     logger.debug("Querying diagnosis keys from the database...");
     Collection<DiagnosisKey> diagnosisKeys = diagnosisKeyService.getDiagnosisKeys();
-    return new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
+    diagnosisKeyBundler.setDiagnosisKeys(diagnosisKeys);
+    return new DiagnosisKeysDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig);
   }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java
@@ -0,0 +1,61 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+import org.springframework.context.annotation.Profile;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}, that will be distributed
+ * in the same hour they have been submitted.
+ */
+@Profile("demo")
+public class DemoDiagnosisKeyBundler extends DiagnosisKeyBundler {
+
+  public DemoDiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    super(distributionServiceConfig);
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, grouping the diagnosis keys by the submission
+   * timestamp, thus ignoring the expiry policy.
+   */
+  @Override
+  protected void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys) {
+    this.distributableDiagnosisKeys.clear();
+    this.distributableDiagnosisKeys.putAll(diagnosisKeys.stream().collect(groupingBy(this::getSubmissionDateTime)));
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour, without respecting the shifting and
+   * expiry policies.
+   */
+  @Override
+  public List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour) {
+    return this.getDiagnosisKeysForHour(hour);
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java
@@ -0,0 +1,98 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.ONE_HOUR_INTERVAL_SECONDS;
+import static java.time.ZoneOffset.UTC;
+import static java.util.Collections.emptyList;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Component;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}.
+ */
+@Component
+public abstract class DiagnosisKeyBundler {
+
+  protected final int minNumberOfKeysPerBundle;
+  protected final long expiryPolicyMinutes;
+
+  // A map containing diagnosis keys, grouped by the LocalDateTime on which they may be distributed
+  protected final Map<LocalDateTime, List<DiagnosisKey>> distributableDiagnosisKeys = new HashMap<>();
+
+  public DiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    this.minNumberOfKeysPerBundle = distributionServiceConfig.getShiftingPolicyThreshold();
+    this.expiryPolicyMinutes = distributionServiceConfig.getExpiryPolicyMinutes();
+  }
+
+  /**
+   * Sets the {@link DiagnosisKey DiagnosisKeys} contained by this {@link DiagnosisKeyBundler} and calls {@link
+   * DiagnosisKeyBundler#createDiagnosisKeyDistributionMap}.
+   */
+  public void setDiagnosisKeys(Collection<DiagnosisKey> diagnosisKeys) {
+    createDiagnosisKeyDistributionMap(diagnosisKeys);
+  }
+
+  /**
+   * Returns all {@link DiagnosisKey DiagnosisKeys} contained by this {@link DiagnosisKeyBundler}.
+   */
+  public List<DiagnosisKey> getAllDiagnosisKeys() {
+    return this.distributableDiagnosisKeys.values().stream()
+        .flatMap(List::stream)
+        .collect(Collectors.toList());
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, which should contain all diagnosis keys, grouped
+   * by the LocalDateTime on which they may be distributed.
+   */
+  protected abstract void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys);
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour.
+   */
+  public abstract List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour);
+
+  /**
+   * Returns the submission timestamp of a {@link DiagnosisKey} as a {@link LocalDateTime}.
+   */
+  protected LocalDateTime getSubmissionDateTime(DiagnosisKey diagnosisKey) {
+    return LocalDateTime.ofEpochSecond(diagnosisKey.getSubmissionTimestamp() * ONE_HOUR_INTERVAL_SECONDS, 0, UTC);
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour.
+   */
+  protected List<DiagnosisKey> getDiagnosisKeysForHour(LocalDateTime hour) {
+    return Optional
+        .ofNullable(this.distributableDiagnosisKeys.get(hour))
+        .orElse(emptyList());
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java
@@ -0,0 +1,135 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.TEN_MINUTES_INTERVAL_SECONDS;
+import static java.time.ZoneOffset.UTC;
+import static java.util.Collections.emptyList;
+import static java.util.stream.Collectors.groupingBy;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.springframework.context.annotation.Profile;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}, that will be distributed while
+ * respecting expiry policy (keys must be expired for a configurable amount of time before distribution) and shifting
+ * policy (there must be at least a configurable number of keys in a distribution). The policies are configurable
+ * through the properties {@code expiry-policy-minutes} and {@code shifting-policy-threshold}.
+ */
+@Profile("!demo")
+public class ProdDiagnosisKeyBundler extends DiagnosisKeyBundler {
+
+  /**
+   * Creates a new {@link ProdDiagnosisKeyBundler}.
+   */
+  public ProdDiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    super(distributionServiceConfig);
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, grouping the diagnosis keys by the date on which
+   * they may be distributed, while respecting the expiry policy.
+   */
+  @Override
+  protected void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys) {
+    this.distributableDiagnosisKeys.clear();
+    this.distributableDiagnosisKeys.putAll(diagnosisKeys.stream().collect(groupingBy(this::getDistributionDateTime)));
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour, while respecting the shifting and expiry
+   * policies.
+   */
+  @Override
+  public List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour) {
+    List<DiagnosisKey> keysSinceLastDistribution = getKeysSinceLastDistribution(hour);
+    if (keysSinceLastDistribution.size() >= minNumberOfKeysPerBundle) {
+      return keysSinceLastDistribution;
+    } else {
+      return emptyList();
+    }
+  }
+
+  /**
+   * Returns a all distributable keys between a specific hour and the last distribution (bundle that was above the
+   * shifting threshold) or the earliest distributable key.
+   */
+  private List<DiagnosisKey> getKeysSinceLastDistribution(LocalDateTime hour) {
+    Optional<LocalDateTime> earliestDistributableTimestamp = getEarliestDistributableTimestamp();
+    if (earliestDistributableTimestamp.isEmpty() || hour.isBefore(earliestDistributableTimestamp.get())) {
+      return emptyList();
+    }
+    List<DiagnosisKey> distributableInCurrentHour = getDiagnosisKeysForHour(hour);
+    if (distributableInCurrentHour.size() >= minNumberOfKeysPerBundle) {
+      return distributableInCurrentHour;
+    }
+    LocalDateTime previousHour = hour.minusHours(1);
+    Collection<DiagnosisKey> distributableInPreviousHour = getDiagnosisKeysDistributableAt(previousHour);
+    if (distributableInPreviousHour.size() >= minNumberOfKeysPerBundle) {
+      // Last hour was distributed, so we can not combine the current hour with the last hour
+      return distributableInCurrentHour;
+    } else {
+      // Last hour was not distributed, so we can combine the current hour with the last hour
+      return Stream.concat(distributableInCurrentHour.stream(), getKeysSinceLastDistribution(previousHour).stream())
+          .collect(Collectors.toList());
+    }
+  }
+
+  private Optional<LocalDateTime> getEarliestDistributableTimestamp() {
+    return this.distributableDiagnosisKeys.keySet().stream().min(LocalDateTime::compareTo);
+  }
+
+  /**
+   * Returns the end of the rolling time window that a {@link DiagnosisKey} was active for as a {@link LocalDateTime}.
+   */
+  private LocalDateTime getExpiryDateTime(DiagnosisKey diagnosisKey) {
+    return LocalDateTime
+        .ofEpochSecond(diagnosisKey.getRollingStartIntervalNumber() * TEN_MINUTES_INTERVAL_SECONDS, 0, UTC)
+        .plusMinutes(diagnosisKey.getRollingPeriod() * 10L);
+  }
+
+  /**
+   * Calculates the earliest point in time at which the specified {@link DiagnosisKey} can be distributed. Before keys
+   * are allowed to be distributed, they must be expired for a configured amount of time.
+   *
+   * @return {@link LocalDateTime} at which the specified {@link DiagnosisKey} can be distributed.
+   */
+  private LocalDateTime getDistributionDateTime(DiagnosisKey diagnosisKey) {
+    LocalDateTime submissionDateTime = getSubmissionDateTime(diagnosisKey);
+    LocalDateTime expiryDateTime = getExpiryDateTime(diagnosisKey);
+    long minutesBetweenExpiryAndSubmission = Duration.between(expiryDateTime, submissionDateTime).toMinutes();
+    if (minutesBetweenExpiryAndSubmission <= expiryPolicyMinutes) {
+      // truncatedTo floors the value, so we need to add an hour to the DISTRIBUTION_PADDING to compensate that.
+      return expiryDateTime.plusMinutes(expiryPolicyMinutes + 60).truncatedTo(ChronoUnit.HOURS);
+    } else {
+      return submissionDateTime;
+    }
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateAggregatingDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
@@ -30,12 +31,11 @@
 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.time.LocalDate;
-import java.util.Collection;
 import java.util.Set;

 public class DiagnosisKeysCountryDirectory extends IndexDirectoryOnDisk<String> {

-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;

@@ -43,14 +43,14 @@ public class DiagnosisKeysCountryDirectory extends IndexDirectoryOnDisk<String>
    * Constructs a {@link DiagnosisKeysCountryDirectory} instance that represents the {@code .../country/:country/...}
    * portion of the diagnosis key directory structure.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
-   * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysCountryDirectory(Collection<DiagnosisKey> diagnosisKeys,
+  public DiagnosisKeysCountryDirectory(DiagnosisKeyBundler diagnosisKeyBundler,
       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
     super(distributionServiceConfig.getApi().getCountryPath(), __ ->
         Set.of(distributionServiceConfig.getApi().getCountryGermany()), Object::toString);
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -58,7 +58,7 @@ public DiagnosisKeysCountryDirectory(Collection<DiagnosisKey> diagnosisKeys,
   @Override
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritableToAll(__ -> {
-      DiagnosisKeysDateDirectory dateDirectory = new DiagnosisKeysDateDirectory(diagnosisKeys, cryptoProvider,
+      DiagnosisKeysDateDirectory dateDirectory = new DiagnosisKeysDateDirectory(diagnosisKeyBundler, cryptoProvider,
           distributionServiceConfig);
       return decorateDateDirectory(dateDirectory);
     });
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.HourIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
@@ -31,13 +32,12 @@
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
-import java.util.Collection;

 public class DiagnosisKeysDateDirectory extends IndexDirectoryOnDisk<LocalDate> {

   private static final DateTimeFormatter ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd");

-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;

@@ -45,14 +45,15 @@ public class DiagnosisKeysDateDirectory extends IndexDirectoryOnDisk<LocalDate>
    * Constructs a {@link DiagnosisKeysDateDirectory} instance associated with the specified {@link DiagnosisKey}
    * collection. Payload signing is be performed according to the specified {@link CryptoProvider}.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained directories.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
    * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysDateDirectory(Collection<DiagnosisKey> diagnosisKeys,
+  public DiagnosisKeysDateDirectory(DiagnosisKeyBundler diagnosisKeyBundler,
       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
-    super(distributionServiceConfig.getApi().getDatePath(), __ -> DateTime.getDates(diagnosisKeys), ISO8601::format);
+    super(distributionServiceConfig.getApi().getDatePath(),
+        __ -> DateTime.getDates(diagnosisKeyBundler.getAllDiagnosisKeys()), ISO8601::format);
     this.cryptoProvider = cryptoProvider;
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.distributionServiceConfig = distributionServiceConfig;
   }

@@ -60,7 +61,7 @@ public DiagnosisKeysDateDirectory(Collection<DiagnosisKey> diagnosisKeys,
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritableToAll(__ -> {
       DiagnosisKeysHourDirectory hourDirectory =
-          new DiagnosisKeysHourDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
+          new DiagnosisKeysHourDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig);
       return decorateHourDirectory(hourDirectory);
     });
     super.prepare(indices);
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.CountryIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
@@ -31,7 +32,6 @@
 import app.coronawarn.server.services.distribution.assembly.structure.directory.decorator.indexing.IndexingDecoratorOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
-import java.util.Collection;

 /**
  * A {@link Directory} containing the file and directory structure that mirrors the API defined in the OpenAPI
@@ -42,7 +42,7 @@
  */
 public class DiagnosisKeysDirectory extends DirectoryOnDisk {

-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;

@@ -50,13 +50,13 @@ public class DiagnosisKeysDirectory extends DirectoryOnDisk {
    * Constructs a {@link DiagnosisKeysDirectory} based on the specified {@link DiagnosisKey} collection. Cryptographic
    * signing is performed using the specified {@link CryptoProvider}.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
-   * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
+  public DiagnosisKeysDirectory(DiagnosisKeyBundler diagnosisKeyBundler, CryptoProvider cryptoProvider,
       DistributionServiceConfig distributionServiceConfig) {
     super(distributionServiceConfig.getApi().getDiagnosisKeysPath());
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -64,7 +64,7 @@ public DiagnosisKeysDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProv
   @Override
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritable(decorateCountryDirectory(
-        new DiagnosisKeysCountryDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig)));
+        new DiagnosisKeysCountryDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig)));
     super.prepare(indices);
   }

diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.archive.decorator.singing.DiagnosisKeySigningDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.file.TemporaryExposureKeyExportFile;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
@@ -36,28 +37,25 @@
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
-import java.util.Collection;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.List;

 public class DiagnosisKeysHourDirectory extends IndexDirectoryOnDisk<LocalDateTime> {

-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;

   /**
    * Constructs a {@link DiagnosisKeysHourDirectory} instance for the specified date.
    *
-   * @param diagnosisKeys  A collection of diagnosis keys. These will be filtered according to the specified current
-   *                       date.
-   * @param cryptoProvider The {@link CryptoProvider} used for cryptographic signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for cryptographic signing.
    */
-  public DiagnosisKeysHourDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
+  public DiagnosisKeysHourDirectory(DiagnosisKeyBundler diagnosisKeyBundler, CryptoProvider cryptoProvider,
       DistributionServiceConfig distributionServiceConfig) {
-    super(distributionServiceConfig.getApi().getHourPath(),
-        indices -> DateTime.getHours(((LocalDate) indices.peek()), diagnosisKeys), LocalDateTime::getHour);
-    this.diagnosisKeys = diagnosisKeys;
+    super(distributionServiceConfig.getApi().getHourPath(), indices -> DateTime.getHours(((LocalDate) indices.peek()),
+        diagnosisKeyBundler.getAllDiagnosisKeys()), LocalDateTime::getHour);
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -71,7 +69,8 @@ public void prepare(ImmutableStack<Object> indices) {
       // IndexDirectory.
       String region = (String) currentIndices.pop().pop().peek();

-      Set<DiagnosisKey> diagnosisKeysForCurrentHour = getDiagnosisKeysForHour(currentHour);
+      List<DiagnosisKey> diagnosisKeysForCurrentHour =
+          this.diagnosisKeyBundler.getDiagnosisKeysDistributableAt(currentHour);

       long startTimestamp = currentHour.toEpochSecond(ZoneOffset.UTC);
       long endTimestamp = currentHour.plusHours(1).toEpochSecond(ZoneOffset.UTC);
@@ -86,12 +85,6 @@ public void prepare(ImmutableStack<Object> indices) {
     super.prepare(indices);
   }

-  private Set<DiagnosisKey> getDiagnosisKeysForHour(LocalDateTime hour) {
-    return this.diagnosisKeys.stream()
-        .filter(diagnosisKey -> DistributionDateTimeCalculator.getDistributionDateTime(diagnosisKey).equals(hour))
-        .collect(Collectors.toSet());
-  }
-
   private Directory<WritableOnDisk> decorateDiagnosisKeyArchive(Archive<WritableOnDisk> archive) {
     return new DiagnosisKeySigningDecorator(archive, cryptoProvider, distributionServiceConfig);
   }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java
deleted file mode 100644
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*-
- * ---license-start
- * Corona-Warn-App
- * ---
- * Copyright (C) 2020 SAP SE and all other contributors
- * ---
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ---license-end
- */
-
-package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
-
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.ONE_HOUR_INTERVAL_SECONDS;
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.TEN_MINUTES_INTERVAL_SECONDS;
-import static java.time.ZoneOffset.UTC;
-
-import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
-import java.time.Duration;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
-
-public class DistributionDateTimeCalculator {
-
-  /**
-   * Minimum time in minutes after key expiration after which it can be distributed.
-   */
-  public static final long DISTRIBUTION_PADDING = 120L;
-
-  private DistributionDateTimeCalculator() {
-  }
-
-  /**
-   * Calculates the earliest point in time at which the specified {@link DiagnosisKey} can be distributed. Before keys
-   * are allowed to be distributed, they must be expired for a configured amount of time.
-   *
-   * @return {@link LocalDateTime} at which the specified {@link DiagnosisKey} can be distributed.
-   */
-  public static LocalDateTime getDistributionDateTime(DiagnosisKey diagnosisKey) {
-    LocalDateTime submissionDateTime = LocalDateTime
-        .ofEpochSecond(diagnosisKey.getSubmissionTimestamp() * ONE_HOUR_INTERVAL_SECONDS, 0, UTC);
-    LocalDateTime keyExpiryDateTime = LocalDateTime
-        .ofEpochSecond(diagnosisKey.getRollingStartIntervalNumber() * TEN_MINUTES_INTERVAL_SECONDS, 0, UTC)
-        .plusMinutes(diagnosisKey.getRollingPeriod() * 10L);
-
-    if (Duration.between(keyExpiryDateTime, submissionDateTime).toMinutes() <= DISTRIBUTION_PADDING) {
-      // truncatedTo floors the value, so we need to add an hour to the DISTRIBUTION_PADDING to compensate that.
-      return keyExpiryDateTime.plusMinutes(DISTRIBUTION_PADDING + 60).truncatedTo(ChronoUnit.HOURS);
-    }
-
-    return submissionDateTime;
-  }
-}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
@@ -31,6 +31,8 @@ public class DistributionServiceConfig {
   private Paths paths;
   private TestData testData;
   private Integer retentionDays;
+  private Integer expiryPolicyMinutes;
+  private Integer shiftingPolicyThreshold;
   private String outputFileName;
   private Boolean includeIncompleteDays;
   private Boolean includeIncompleteHours;
@@ -63,6 +65,22 @@ public void setRetentionDays(Integer retentionDays) {
     this.retentionDays = retentionDays;
   }

+  public Integer getExpiryPolicyMinutes() {
+    return expiryPolicyMinutes;
+  }
+
+  public void setExpiryPolicyMinutes(Integer expiryPolicyMinutes) {
+    this.expiryPolicyMinutes = expiryPolicyMinutes;
+  }
+
+  public Integer getShiftingPolicyThreshold() {
+    return shiftingPolicyThreshold;
+  }
+
+  public void setShiftingPolicyThreshold(Integer shiftingPolicyThreshold) {
+    this.shiftingPolicyThreshold = shiftingPolicyThreshold;
+  }
+
   public String getOutputFileName() {
     return outputFileName;
   }
diff --git a/services/distribution/src/main/resources/application.yaml b/services/distribution/src/main/resources/application.yaml
--- a/services/distribution/src/main/resources/application.yaml
+++ b/services/distribution/src/main/resources/application.yaml
@@ -11,6 +11,8 @@ services:
   distribution:
     output-file-name: index
     retention-days: 14
+    expiry-policy-minutes: 120
+    shifting-policy-threshold: 140
     include-incomplete-days: false
     include-incomplete-hours: false
     paths:

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java
@@ -0,0 +1,190 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@EnableConfigurationProperties(value = DistributionServiceConfig.class)
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = {DistributionServiceConfig.class, ProdDiagnosisKeyBundler.class},
+    initializers = ConfigFileApplicationContextInitializer.class)
+class ProdDiagnosisKeyBundlerTest {
+
+  @Autowired
+  DistributionServiceConfig distributionServiceConfig;
+
+  @Autowired
+  DiagnosisKeyBundler bundler;
+
+  @Test
+  void testEmptyListWhenNoDiagnosisKeys() {
+    bundler.setDiagnosisKeys(List.of());
+    assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 1, 0, 0, 0))).hasSize(0);
+  }
+
+  @Test
+  void testEmptyListWhenGettingDistributableKeysBeforeEarliestDiagnosisKey() {
+    List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 5);
+    bundler.setDiagnosisKeys(diagnosisKeys);
+    assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 1, 0, 0, 0))).hasSize(0);
+  }
+
+  @Nested
+  @DisplayName("Expiry policy")
+  class ProdDiagnosisKeyBundlerExpiryPolicyTest {
+
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 24L, 24L + 2L})
+    void testLastPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, submissionTimestamp, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 3, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testLastPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, 24L + 3L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 3, 0, 0))).hasSize(10);
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 24L, 24L + 2L, 24L + 3L})
+    void testFirstPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, submissionTimestamp, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testFirstPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 24L + 4L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testLastPeriodOfHourAndSubmissionGreaterDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, 24L + 4L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+  }
+
+  @Nested
+  @DisplayName("Shifting policy")
+  class ProdDiagnosisKeyBundlerShiftingPolicyTest {
+
+    @Test
+    void testDoesNotShiftIfPackageSizeGreaterThanThreshold() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 6);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(6);
+    }
+
+    @Test
+    void testDoesNotShiftIfPackageSizeEqualsThreshold() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 5);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsIfPackageSizeLessThanThreshold() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .concat(buildDiagnosisKeys(6, 50L, 4).stream(), buildDiagnosisKeys(6, 51L, 1).stream())
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsSinceLastDistribution() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .of(buildDiagnosisKeys(6, 50L, 5), buildDiagnosisKeys(6, 51L, 2), buildDiagnosisKeys(6, 52L, 4))
+          .flatMap(List::stream)
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(5);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 4, 0, 0))).hasSize(6);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 5, 0, 0))).hasSize(0);
+    }
+
+    @Test
+    void testShiftIncludesPreviouslyUndistributedKeys() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .concat(buildDiagnosisKeys(6, 50L, 1).stream(), buildDiagnosisKeys(6, 51L, 5).stream())
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsSparseDistributions() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .of(buildDiagnosisKeys(6, 50L, 1), buildDiagnosisKeys(6, 51L, 1), buildDiagnosisKeys(6, 52L, 1),
+              buildDiagnosisKeys(6, 53L, 0), buildDiagnosisKeys(6, 54L, 0), buildDiagnosisKeys(6, 55L, 1),
+              buildDiagnosisKeys(6, 56L, 1))
+          .flatMap(List::stream)
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 4, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 5, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 6, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 7, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 8, 0, 0))).hasSize(5);
+    }
+  }
+
+  public static List<DiagnosisKey> buildDiagnosisKeys(int startIntervalNumber, long submissionTimestamp, int number) {
+    return IntStream.range(0, number)
+        .mapToObj(__ -> DiagnosisKey.builder()
+            .withKeyData(new byte[16])
+            .withRollingStartIntervalNumber(startIntervalNumber)
+            .withTransmissionRiskLevel(2)
+            .withSubmissionTimestamp(submissionTimestamp).build())
+        .collect(Collectors.toList());
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
@@ -26,6 +26,8 @@

 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DemoDiagnosisKeyBundler;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.DirectoryOnDisk;
@@ -33,7 +35,6 @@
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.io.File;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
@@ -90,8 +91,8 @@ void setupAll() throws IOException {

   @Test
   void checkBuildsTheCorrectDirectoryStructureWhenNoKeys() {
-    diagnosisKeys = new ArrayList<>();
-    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider,
+    DiagnosisKeyBundler bundler = new DemoDiagnosisKeyBundler(distributionServiceConfig);
+    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(bundler, cryptoProvider,
         distributionServiceConfig);
     parentDirectory.addWritable(directory);
     directory.prepare(new ImmutableStack<>());
@@ -111,7 +112,9 @@ void checkBuildsTheCorrectDirectoryStructureWhenNoKeys() {

   @Test
   void checkBuildsTheCorrectDirectoryStructure() {
-    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider,
+    DiagnosisKeyBundler bundler = new DemoDiagnosisKeyBundler(distributionServiceConfig);
+    bundler.setDiagnosisKeys(diagnosisKeys);
+    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(bundler, cryptoProvider,
         distributionServiceConfig);
     parentDirectory.addWritable(directory);
     directory.prepare(new ImmutableStack<>());
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java
deleted file mode 100644
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*-
- * ---license-start
- * Corona-Warn-App
- * ---
- * Copyright (C) 2020 SAP SE and all other contributors
- * ---
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ---license-end
- */
-
-package app.coronawarn.server.services.distribution.assembly.structure.directory;
-
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.DistributionDateTimeCalculator.getDistributionDateTime;
-import static org.assertj.core.api.Assertions.assertThat;
-
-import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-class DistributionDateTimeCalculatorTest {
-
-  @ParameterizedTest
-  @ValueSource(longs = {0L, 24L, 24L + 2L})
-  void testLastPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
-    var diagnosisKey = buildDiagnosisKey(5, submissionTimestamp);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T03:00");
-  }
-
-  @Test
-  void testLastPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(5, 24L + 3L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T03:00");
-  }
-
-  @ParameterizedTest
-  @ValueSource(longs = {0L, 24L, 24L + 2L, 24L + 3L})
-  void testFirstPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
-    var diagnosisKey = buildDiagnosisKey(6, submissionTimestamp);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  @Test
-  void testFirstPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(6, 24L + 4L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  @Test
-  void testLastPeriodOfHourAndSubmissionGreaterDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(5, 24L + 4L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  private DiagnosisKey buildDiagnosisKey(int startIntervalNumber, long submissionTimestamp) {
-    return DiagnosisKey.builder()
-        .withKeyData(new byte[16])
-        .withRollingStartIntervalNumber(startIntervalNumber)
-        .withTransmissionRiskLevel(2)
-        .withSubmissionTimestamp(submissionTimestamp).build();
-  }
-}
diff --git a/services/distribution/src/test/resources/application.yaml b/services/distribution/src/test/resources/application.yaml
--- a/services/distribution/src/test/resources/application.yaml
+++ b/services/distribution/src/test/resources/application.yaml
@@ -9,6 +9,8 @@ services:
   distribution:
     output-file-name: index
     retention-days: 14
+    expiry-policy-minutes: 120
+    shifting-policy-threshold: 5
     include-incomplete-days: false
     include-incomplete-hours: false
     paths:

Aggregate File Creation: Skip packages if payload too small
Change the aggregate file creation logic in a way, in which packages are skipped in case the payload (amount of keys) is too small. The threshold should be configurable, and defaulted to 50 for now.

Maybe combine with #140
**Alternative suggestion: Pad packages with random dummy keys if too small**

Thinking a bit further into the hoped for future, when new infections are becoming rare, this might lead to unnecessary (possibly quite long) delays in notifications being sent. Could one instead use random dummy keys to pad every package that is too small to maybe 50 or 100 keys (or possibly padding to a randomised amount so an outsider can't even determine whether there any dummies are in the set).

This would impact required data volumes marginally - in the worst case, if 75 dummy keys are added every hour, this would represent only 50 kiB per day (or 3% of your bandwidth example in the architecture document) if I did not miscalculate. Since this would only occur in a low load situation anyway, this should be acceptable.
Threhold is now: 140
I like the padding idea - @MKusber do you think we can do this from a DPP/security perspective?
Hey @MikeJayDee, after some investigation, @michael-burwig and me decided that we will go for the "shifting" approach instead of the "padding" approach for this release. For clarification:

**"Shifting" approach:**
- If a package is too small (number of keys submitted in a timeframe is below a configurable threshold), then it will not be distributed (there will be zero keys in that package)
- The keys that would have been distributed in that run are "shifted" to the next distribution run
- Rinse and repeat until a package can be assembled that contains enough keys (above the configurable threshold)

**"Padding" approach:**
- If a package is too small, then it will be padded with random / fake keys until the threshold is matched (or exceeded)

As you have rightly pointed out, the main disadvantage of the "shifting" approach is that it introduces an artificial delay in the distribution of keys. This means that people will not be warned about a potential COVID exposure as quickly as possible, but that the CWA server will wait with the distribution until enough submissions have been made. Especially as the number of submissions decreases, this delay will become significant.

This disadvantage, of course, doesn't exist with the "padding" approach. However, the purpose of this whole "threshold" feature is to preserve peoples anonymity and to not allow anyone to create movement profiles. In order to achieve that, it must be impossible to match a specific temporary exposure key (TEK) to any particular person. In the "shifting" approach, this is achieved by ensuring that a single person's TEKs are always mashed together with enough other people's real TEKs to get lost in the shuffle. In the "padding" approach, this is achieved by mashing a single person's TEKs together with artificially/randomly generated TEKs. In order to make the "padding" approach equally as secure and privacy preserving, we would need to make real TEKs and artificially/randomly generated TEKs absolutely indistinguishable from one another. We decided for the "shifting" approach for this release because we are not 100% certain that we can achieve that - for the following reasons:

There are 4 fields in a TEK:
- Key data
- Transmission risk level
- Rolling start interval number
- Rolling period

"Key data" is random by nature and can easily be generated - no problem there. "Rolling period" is always 144 - no problem here either. However, in the real world, "transmission risk levels" and "rolling start interval numbers" each follow some statistical distribution. In order to make real and fake TEKs indistinguishable to any reasonably certainty, we would need to make sure, that the fake TEKs follow the same distribution as the real ones. The problem is, that we are not sure what the exact properties of that distribution are (and how they change over time). We can thus not confidently guarantee that one wouldn't be able to distinguish random and real TEKs through statistical analysis.

Since data privacy and protection are the foundation of this whole project, we decided to not take this risk and go with the safer but less accurate solution.

Now, it might be entirely possible that there is a solution to this problem that we just don't see. But since time is of the essence right now (and we can not wait for a scientifically substantiated analysis of the "padding" approach), we decided to go for the "shifting" approach for the initial release.

Anyways. Thank you very much for sharing your idea, @MikeJayDee. We forwarded your idea and the open questions outlined above to our scientific partners and will keep you posted on any updates 👍