Short answer: for game receipt document retention, make storage selection around private object storage, a lifecycle delete policy, and a database ledger; use a separate archive when the policy requires exact-time deletion, legal hold, or immutable recovery.
The deciding constraint is access control versus delivery simplicity. A receipt processor has two different jobs that are easy to confuse: accepting a file reliably, and proving later which original file belonged to which purchase. A public URL makes delivery easy and audit harder. A fully proxied download makes authorization clearer and puts more bandwidth, latency, and failure handling on the application. Choose the control boundary first.
This is an SLO and capacity-planning problem, too. Define the maximum time to ingest a receipt, the maximum time to make an authorized export available, and the deletion lag the retention policy permits. Then budget bytes for originals, derived previews, export bundles, backups, and the expiry window. The monthly bill is a consequence of those decisions, not the architecture.
How can storage selection protect document retention before lifecycle delete?
Start with an object key that carries no authorization meaning by itself. A key such as receipts/2026/08/account-17/purchase-0042/original.bin is useful for partitioning and operations, but the database must still decide whether a caller may read it. Store the object identifier, content type, byte count, checksum, receipt status, account identifier, and retention class in a transactionally managed record. Keep the original immutable from the application’s point of view; a corrected interpretation should create a new derived record rather than silently replacing evidence.
The receipt flow should have explicit states: accepted, stored, parsed, rejected, exported, and eligible for deletion. A worker can parse a receipt after the upload succeeds, but the parser's result must not become the only copy of the evidence. If parsing fails, retain the original and record the failure for review. If an export is generated, give it its own retention class and key prefix. That distinction prevents a short-lived download bundle from inheriting the longer policy for an original receipt.
For delivery, there are three defensible patterns. The application can stream every download after checking the session and account, it can mint a short-lived signed URL after that check, or it can place a download token in front of a gateway that performs the check. The first maximizes central control but consumes application egress. The second is operationally simpler but requires careful expiry, audience, and object-key binding. The third can suit a large platform, although it adds another policy surface to observe and test.
Keep the bucket private.
Do not put account identifiers, email addresses, or receipt text in a URL that may reach logs, browser history, analytics, or support tickets. A token should identify one permitted action for one object and one short interval. It should not become a reusable capability for an entire account.
What fails when retention, backups, and delivery share one clock?
The common failure is an apparently tidy lifecycle rule that has no owner in the application. A scheduled export writes under a new prefix, the lifecycle selector does not match it, and the byte curve rises while the dashboard still reports healthy request latency. Another failure deletes the database row first, leaving an orphaned original that nobody can locate through normal product queries. The reverse failure is worse for audit: an object is deleted while its receipt record still says the original is available.
Day-level deletion is not an exact-time deletion contract. If the minimum lifecycle period is one day, it is appropriate for temporary exports and other data whose policy tolerates that window; it is unsuitable for a requirement such as “erase this file within the next hour.” Record eligible_at in the database, measure the age of eligible objects, and describe the storage guarantee as a deletion-lag SLO rather than pretending the lifecycle scheduler is a real-time queue.
Backups need a separate clock. A database backup can preserve the receipt metadata after the object has expired, while an object backup can preserve the original after the application believes the account was deleted. Neither outcome is automatically wrong, but both must be included in the policy. Document which backup copies contain receipt payloads, how long they remain restorable, and what an account deletion request means for restore procedures. A recovery test that restores only the database is not evidence that the original file is recoverable.
I'm not sure a one-day floor is acceptable for your retention policy without the legal and recovery requirements; those are inputs to the design, not details to discover during an incident.
Measure the retention system like a production dependency
Track object count and retained bytes by retention class, key prefix, age bucket, and environment. Watch the oldest eligible object, not only total capacity. A total can remain flat while a forgotten export prefix accumulates data and a separate class expires normally.
The useful reconciliation is between two ledgers. The application ledger says which objects should exist and which are eligible. The storage inventory says what actually exists. Sample both during the first two expiry windows after a policy change, compare counts by class, and investigate any gap before it becomes a capacity event. A checksum comparison for selected originals also catches a pipeline that reports success while storing a truncated or transformed file.
Set an ingest SLO and a delivery SLO separately. For example, the service may promise that an accepted receipt is durable before acknowledging the purchase workflow, while an authorized export request may have a different target because it runs through a queue. The exact values belong to the game’s traffic and support model; the important part is that the thresholds are measurable, paged only when action is possible, and reviewed against peak-season load.
Capacity planning should include the expiry tail. A first approximation is:
peak bytes = daily original ingest * original retention days + daily export bytes * export retention days + backup reserve + recovery margin
The formula is deliberately incomplete. Multipart uploads, failed jobs, retries, replicas, and a delayed lifecycle pass can all extend the high-water mark. Measure those terms from production-like load tests, then make the margin visible in the budget rather than hiding it inside an unexplained percentage.
Buy or build the control plane for receipt storage?
The real comparison is not “which bucket has the nicest upload API.” It is how many credential stores, billing surfaces, lifecycle dialects, audit trails, and on-call runbooks the platform team will own. A managed object store can reduce the amount of durable storage machinery the team builds; a self-hosted service can offer more control while making upgrades, replication, repair, and incident response part of the team’s standing work.
| Operating model | Good fit | Cost and on-call trade-off | Not suitable when |
|---|---|---|---|
| Direct managed object storage | The team wants provider-operated durability and already has compatible identity controls | Less storage machinery to operate, but credentials, lifecycle rules, and provider billing remain separate concerns | The required retention or access policy is absent after verification |
| Self-hosted object storage | The organization can staff replication, upgrades, repair, and recovery testing | More control over placement and policy, with a larger operational surface and capacity obligation | The platform team cannot maintain storage during a game launch or regional incident |
| Application-mediated delivery | Authorization rules are complex and audit evidence must be centralized | More application bandwidth, queues, and latency paths to monitor | The download volume would make the application a bottleneck |
| Signed delivery | Files are large or frequent and the storage edge should serve bytes directly | Simpler data delivery, but token scope, expiry, leakage, and revocation need testing | Immediate revocation is required and the delivery layer cannot enforce it |
The catch is that no operating model supplies the policy ledger for free. The platform still needs ownership for key naming, retention classes, account deletion, backup restoration, and evidence collection. Stick with a direct managed service when the organization’s identity, audit, and recovery controls already match it. Select a self-hosted design only when placement or policy control justifies the permanent on-call load.
Verify a policy change and roll it back without guessing
Treat lifecycle configuration as reviewed infrastructure. Before applying it, generate a dry-run candidate list from the database, check that every candidate maps to the intended prefix and retention class, and ask a second operator to inspect the cutoff. Do not use an object-store listing as the source of truth for authorization or account deletion.
This small Go preflight makes the day-level boundary visible. It is intentionally independent of a provider SDK, so the same check can run in CI or as a release gate.
package main
import (
"fmt"
"os"
"strconv"
"time"
)
type receiptObject struct {
key string
createdAt time.Time
}
func main() {
days, err := strconv.Atoi(os.Getenv("RETENTION_DAYS"))
if err != nil || days < 1 {
fmt.Fprintln(os.Stderr, "RETENTION_DAYS must be at least 1")
os.Exit(2)
}
cutoff := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
objects := []receiptObject{
{key: "receipts/2026/08/account-17/purchase-0042/original.bin", createdAt: cutoff.Add(-time.Hour)},
{key: "exports/2026/08/account-17/monthly.zip", createdAt: cutoff.Add(time.Hour)},
}
for _, object := range objects {
if !object.createdAt.After(cutoff) {
fmt.Printf("eligible\t%s\t%s\n", object.key, object.createdAt.Format(time.RFC3339))
}
}
}
After deployment, read back the effective policy and compare it with the approved class and prefix. During the first expiry window, reconcile eligible database rows with storage inventory and retained bytes. Verify that an active receipt remains readable through the intended authorization path, that an expired export is no longer offered by the application, and that an audit record explains each deletion decision.
Rollback means restoring the last reviewed policy before more data crosses the eligibility boundary. Pause destructive application jobs, preserve the retention ledger, read back the restored configuration, and resume only after the reconciliation checks pass. Do not promise recovery of payloads deleted without versioning or an immutable archive. If the policy requires that guarantee, route the class to a storage system with the needed controls before enabling automated deletion.












