Short answer: keep an uploaded screenshot in quarantine until its bytes, decoded metadata, and retention state all pass the same validation record. Only then should a support ticket receive an attachment reference. This is the least complex way I know to avoid paying to cache a file that an agent cannot open later.
The practical problem is larger than checking a .png suffix. A browser can send a file with a friendly name, a missing MIME type, an unexpected color profile, or dimensions that turn a small ticket into a costly thumbnail job. The intake path also has to survive retries: a customer may click upload twice, an agent may reopen a ticket next week, and a cleanup worker may race the ticket writer.
Why screenshot intake fails after the upload appears successful
Support systems usually have two clocks. The first is the request clock: receive bytes, return an upload result, and let the customer continue. The second is the evidence clock: prove that the same immutable object can be decoded, displayed, and retained for the ticket's policy window. Treating the first clock as proof of the second creates quiet failures.
The common example is a 200 response followed by a broken preview. The object may have been truncated by a proxy, accepted with an image media type that the decoder rejects, or written to a temporary key that an expiry rule removes overnight. A 413 should mean the request was too large; a 415 should mean the media type is not acceptable. Neither status says anything about whether a stored object will still be readable in 30 days.
I record the upload as a state machine instead of a boolean. received means bytes arrived. decoded means an allow-listed decoder read the image and produced dimensions. attached means a ticket points at the immutable object. expired is a normal terminal state after the retention window, while rejected carries a reason that is safe to show to the customer. That vocabulary makes retries idempotent and makes a cleanup report useful.
The storage bill follows this model. A 4 MB original, three generated thumbnails, and seven days of duplicate retry objects can cost more than the moderation step that looked expensive in the initial estimate. I therefore measure bytes retained per ticket, not only bytes uploaded per request.
How should metadata inspection and lifecycle validation shape ticket attachments?
The answer is to make metadata a signed decision record, not a loose set of log lines. Store the object key, content length, detected media type, width, height, decoder name, hash, and validation timestamp together. The ticket stores the record identifier and a read-only reference. It never guesses the type from the filename later.
Here is a deliberately boring TypeScript boundary. The storage and decoder implementations can be local, managed, or self-hosted; the contract stays the same.
type IntakeState = "received" | "decoded" | "attached" | "rejected" | "expired";
type ImageEvidence = {
objectKey: string;
sha256: string;
bytes: number;
mediaType: string;
width: number;
height: number;
state: IntakeState;
checkedAt: string;
};
type Decoder = (input: Uint8Array) => Promise<{
mediaType: string;
width: number;
height: number;
}>;
const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp"]);
const maxBytes = 8 * 1024 * 1024;
const maxPixels = 40_000_000;
export async function inspectScreenshot(
objectKey: string,
bytes: Uint8Array,
sha256: string,
decode: Decoder,
): Promise<ImageEvidence> {
if (bytes.byteLength > maxBytes) {
throw new Error("screenshot exceeds the 8 MiB intake limit");
}
const metadata = await decode(bytes);
if (!allowedTypes.has(metadata.mediaType)) {
throw new Error(`unsupported decoded media type: ${metadata.mediaType}`);
}
if (metadata.width * metadata.height > maxPixels) {
throw new Error("screenshot exceeds the pixel limit");
}
return {
objectKey,
sha256,
bytes: bytes.byteLength,
mediaType: metadata.mediaType,
width: metadata.width,
height: metadata.height,
state: "decoded",
checkedAt: new Date().toISOString(),
};
}
The important detail is that the decoder observes the bytes retrieved from quarantine, not a client-supplied header. Hashing those same bytes gives deduplication a stable key and lets a later ticket read verify that the object has not changed. A decoder must also be bounded: pixel limits protect memory even when the compressed file is small.
I keep the original and derivatives under separate retention rules. The original is evidence; a thumbnail is a convenience cache. If a thumbnail can be regenerated from the original, it should not decide whether an attachment is valid. This separation is a small schema choice with a large effect on cache churn.
A lifecycle that can survive retries and cleanup
An intake worker should claim one object version at a time. The sequence I use is: write to a quarantine namespace, calculate a digest, decode and inspect, promote the immutable object, then attach the evidence ID to the ticket in one idempotent transaction. If the worker crashes after promotion, the digest lets a retry find the existing evidence instead of creating another copy.
The ticket write needs a conditional transition. An attached record may be referenced by several internal views, but a rejected record must never be promoted by a late retry. Likewise, cleanup should select only objects whose evidence state is expired; deleting every object older than seven days is how legitimate escalations lose their screenshots.
One short rule helps here:
Do not attach a path. Attach evidence.
That distinction also improves access control. The ticket service can request a short-lived read URL for an evidence ID, while the object store remains private. Logging the evidence ID, hash, and policy version gives an auditor enough context without copying customer screenshots into application logs.
Cost and cache decisions that are easy to miss
Storage and cache cost are the decision axis for this workflow, so I model three numbers per ticket: original bytes retained, derivative bytes retained, and read amplification from previews. A thumbnail cache with a long time-to-live looks efficient until a customer replaces an image five times; stale variants then occupy more space than the final evidence.
There is a useful trade-off table:
| Choice | Helps | Costs or risk |
|---|---|---|
| Keep originals for the full policy window | Strong auditability and reprocessing | Highest retained bytes |
| Keep only normalized images | Predictable rendering | Loses forensic detail and may alter evidence |
| Generate thumbnails on first agent view | Lower idle cache | First-view latency and bursty decoder load |
| Precompute thumbnails at intake | Stable agent latency | Pays for images nobody opens |
| Deduplicate by content hash | Fewer duplicate objects | Requires careful access checks for shared content |
The right choice depends on policy, not a universal percentage. A regulated support queue may need the original bytes; a low-risk internal tool may keep a normalized copy and a hash. Your mileage may vary, and I would not pick a retention period until legal, support, and storage owners sign the same policy version.
What to test before handing evidence to an agent
Unit tests should cover decoder rejection, oversized dimensions, duplicate hashes, and every legal state transition. Integration tests should upload a real PNG, JPEG, and WebP, then fetch each through the same read path an agent uses. Include a truncated body and a wrong header; the expected result is rejection with no ticket attachment.
The lifecycle test is the one teams skip. Create evidence, attach it, advance a fake clock past the retention boundary, run cleanup, and verify that an active ticket still resolves until its policy says expired. Then repeat the test with two concurrent attachment requests. There should be one evidence record, one immutable object, and a deterministic response to both callers.
At runtime, alert on three ratios: rejected uploads, objects in decoded for too long, and ticket references whose read check fails. Also sample the byte distribution by media type. A sudden jump in median PNG size often points to a client capture setting, while a rise in duplicate hashes may indicate a retry loop rather than new customer evidence.
The operational checklist is prose because it belongs in the runbook: confirm limits and allowed types, verify decoder and hash versions, dry-run cleanup against a copy of production metadata, inspect cache hit and miss costs, and rehearse restoring one expired object under the documented policy. If any step needs a manual database edit, the lifecycle contract is not finished.
References
Further reading:
Top comments (0)