truuimage · Legal
Data Retention
Last updated: 2026-07-29
Draft — not legal advice. This document was written by an engineer, not a lawyer, and has not been reviewed by one. It is a starting point, not a document anyone should rely on or be bound by, until a qualified lawyer licensed in the operator's jurisdiction has reviewed and approved it. Every value shown in [BRACKETS] below is a placeholder the operator has not yet filled in.
Overview
This page states, per category of data, what truuimage keeps, for how long, and the specific mechanism in the code that enforces it — not a general promise, but the actual behaviour. One scheduled task, run every 5 minutes, executes twelve independent cleanup steps that do almost all of the automatic cleanup described below: expiring abandoned scans and refunding their credit, retrying any original-image delete that did not succeed the first time, removing expired upload tickets and the bytes they point at, pruning old rate-limit counters (both this app's own table and Better Auth's separate one), pruning old audit-log, revoked-API-key, and Stripe-webhook-event rows, pruning expired sessions and expired sign-in/verification codes, settling any purchase that has sat unresolved for more than a day by checking directly with Stripe, and retrying any account-deletion request that did not finish on its first attempt (see "Account data and deletion" below).
What we keep, and for how long
- Original uploaded image
What's kept: The full-resolution image bytes you submit for a scan.
Retention window: Deleted as soon as the scan finishes — completed, failed, or expired — typically within seconds; up to about 5 minutes plus one cron cycle in the stuck-scan case below. Honest edge case: if the stuck-scan expiry keeps failing to refund the credit it owes back, the scan never reaches a final status, and its original is retained until that failure is fixed.
Why / mechanism: runScan's finally block (src/lib/scans/lifecycle.ts) always calls storage.delete on the original, whatever happened above it. If that delete fails, sweepUndeletedBlobs retries it on every 5-minute reconciliation cron tick until it succeeds — but only for a scan that has actually reached a terminal status (completed/failed/expired); it deliberately never sweeps a processing/queued scan, which would race runScan's own in-flight fetch-then-delete of the same key (see sweepUndeletedBlobs's own doc comment). A scan whose stuck-scan expiry keeps failing to refund therefore never reaches a terminal status, never reaches this sweep, and its original is retained until that underlying failure is fixed — see "Abandoned / stuck scan" below for the same failure mode.
- Abandoned upload
What's kept: Bytes you uploaded that never turned into a scan (e.g. you closed the tab mid-upload).
Retention window: Removed within roughly 5–10 minutes of being abandoned.
Why / mechanism: The upload ticket backing it expires after 5 minutes (UPLOAD_TOKEN_TTL_MS); pruneUploadMint deletes both the stored object and the ticket on the next 5-minute cron tick.
- Thumbnail
What's kept: A 512px-long-edge WebP downscale of your image, with EXIF, GPS, ICC, IPTC, and XMP metadata stripped.
Retention window: Indefinitely, for as long as your account and its scan history exist.
Why / mechanism: makeThumbnail (src/lib/images/thumbnail.ts) strips metadata by construction, not by request (sharp's default behaviour). The thumbnail is kept so your scan report keeps working; there is no automatic prune for it while your account is active — deleting your account removes it immediately, self-service, no manual request needed; see "Account data and deletion" below.
- Scan record
What's kept: Status, filename, byte count, a content hash, the verdict, the confidence score and tier, and timestamps.
Retention window: Indefinitely, for as long as your account exists.
Why / mechanism: Your scan history. No automatic prune exists; see "Account data" below.
- Raw detector result
What's kept: The detection model's full response payload, stored verbatim (the scan row's raw_result column).
Retention window: Indefinitely, in the same row as the scan record above.
Why / mechanism: Retained so the report page's evidence breakdown can be re-rendered later without re-running the model.
- Scan feedback (your corrections)
What's kept: The verdict you say is actually correct, an optional generator you named, and any free-text notes you chose to type — exactly as you wrote them; we do not redact or review them first. The whole row is retained together; there is no partial-deletion path that keeps some of these fields and drops others.
Retention window: Indefinitely while your account exists — no automatic prune exists for it on its own, because it is tied to the scan record it belongs to, which is itself retained indefinitely (see the row above). Deleting your account deletes every scan_feedback row outright — that is a real, working deletion path today, not the same thing as the scan row itself being deleted (it is not — see "Account data and deletion" below for exactly what each table does on deletion).
Why / mechanism: scan_feedback (src/db/schema/detection.ts), written by submitFeedbackAction (src/app/(app)/app/scans/[id]/actions.ts) when you use the "Was this correct?" control on a scan's report page. Kept to help us understand where the detector gets it wrong. deleteScanFeedback (src/lib/account/deletion.ts) deletes every feedback row tied to your scans as part of account deletion.
- Abandoned / stuck scan
What's kept: A scan that never reached a terminal status — e.g. a worker crashed mid-analysis.
Retention window: Automatically expired, and its credit refunded, once it has been non-terminal for 5 minutes.
Why / mechanism: reconcileStuckScans (src/lib/scans/lifecycle.ts), run every 5 minutes by the same reconciliation cron, moves it to expired and refunds the charge. Honest edge case: if the refund keeps failing (not a transient blip, a persistent bug), the expire-and-refund transaction rolls back every time and the scan never leaves queued/processing — visibly so (it never resolves), rather than silently losing the credit. See reconcileStuckScans's own doc comment for the full reasoning.
- Audit log entries
What's kept: Security-relevant events — sign-in, sign-up, API-key creation/revocation, billing reconciliation — each with an IP address and user-agent string when the event arrived on an attributable HTTP request. Several call sites (API-key actions, settings actions, some auth database hooks) record the event without a request in hand, and those two fields are simply absent (NULL) on that row.
Retention window: 400 days.
Why / mechanism: AUDIT_LOG_RETENTION_MS (src/lib/audit.ts); pruned by pruneAuditLog on the reconciliation cron.
- Rate-limit counters — application routes
What's kept: How many requests a given API key or IP address made against this app's own routes, in a fixed time window.
Retention window: 1 day past the window it counted.
Why / mechanism: pruneRateLimits (src/lib/security/rate-limit.ts), same cron. Table: app_rate_limit.
- Rate-limit counters — sign-in / sign-up (Better Auth)
What's kept: A separate counter Better Auth keeps for itself, keyed by IP address and the specific sign-in/sign-up/OTP path requested — a different table from the application one above.
Retention window: At most 25 hours past the last request in that bucket (the widest window any of this app's rules for it uses, plus the same one-day buffer the application table above gets).
Why / mechanism: pruneAuthRateLimits (src/lib/security/rate-limit.ts), same cron. Table: rate_limit (Better Auth's own, storage: 'database' in src/lib/auth/server.ts). Before this was added, nothing ever pruned this table — Better Auth's own built-in opportunistic cleanup only fires for a key that gets reused after its window resets, which a one-off request never triggers.
- Session (sign-in state)
What's kept: Your session token, plus — for security — the IP address and user-agent string of the device that signed in.
Retention window: Up to 30 days, pruned once the session's own expiry has actually passed.
Why / mechanism: session.expiresAt (src/db/schema/auth.ts) is set 30 days out at sign-in and refreshed on continued use (session.expiresIn/updateAge, src/lib/auth/server.ts). Better Auth itself already refuses any session past that expiry, unconditionally, before this app ever sees the request — pruneExpiredSessions (src/lib/auth/prune.ts) only ever removes a row Better Auth had already, independently, stopped trusting; pruned by the same cron.
- Sign-in & verification codes
What's kept: A one-time code and the email address it was sent to (the verification table's identifier column), for email verification, sign-in, and password reset.
Retention window: Expires with the code itself (a short, fixed number of minutes — see OTP_EXPIRES_SECONDS, src/lib/auth/server.ts), then pruned by the next cron pass at the latest.
Why / mechanism: Better Auth refuses an expired code itself before pruneExpiredVerifications (src/lib/auth/prune.ts) ever removes the row, so pruning here can never invalidate a code still capable of being used; pruned by the same cron.
- Revoked API keys
What's kept: A revoked key's name, display prefix, and last-used time. Never the secret itself.
Retention window: 90 days from the moment it was revoked.
Why / mechanism: REVOKED_KEY_RETENTION_MS (src/lib/api-keys/service.ts); pruned by pruneRevokedApiKeys on the same cron.
- Live API keys
What's kept: Only a SHA-256 hash of the key, a display prefix, and usage metadata — never the plaintext secret.
Retention window: For as long as the key remains unrevoked.
Why / mechanism: hashApiKey (src/lib/api-keys/format.ts). The plaintext secret is shown once, at creation, and never stored.
- Purchases, credit ledger, and current balance
What's kept: Which pack you bought, the amount, currency, Stripe identifiers (including the checkout session and payment intent ids), and every individual movement of your credit balance (an append-only ledger) — plus a separate cached row holding your current balance, updated on every movement.
Retention window: Indefinitely, as a financial and accounting record, linked to your account.
Why / mechanism: purchase, credit_ledger, and user_balance (src/db/schema/billing.ts) all store your plain account id — none is a foreign key to the user table, but none is anonymised either; that is a difference in how deletion CASCADEs, not in what the row identifies you by. Kept unlinked-by-foreign-key specifically so these records can survive your account being deleted, rather than being destroyed by that deletion or blocking it outright — deletion anonymises the account record these three still point at (see "Account data and deletion" below), it does not touch these tables themselves.
- Account data
What's kept: Your name, email address, password hash, and two-factor secret if enabled.
Retention window: For as long as your account exists — see "Account data and deletion" below for exactly what changes the moment you delete it.
Why / mechanism: You can delete your own account from account settings; no manual request is needed. See "Account data and deletion" below for the mechanism and the full breakdown.
Account data and deletion
Deletion is self-service, from a destructive-action panel on your account settings page: type your account email address to confirm, and processDeletionRequest runs against it immediately. A durable, per-account deletion_request row (src/db/schema/admin.ts) records that a deletion was asked for and its progress. The very first thing that attempt does — before touching storage, Stripe, or anything else — is revoke your password, two-factor secret, every API key, and every signed-in session, and anonymise your account row; nothing can authenticate as this account again from that point on, no matter how long the rest of the process takes. If that same attempt cannot finish everything else in one pass — most commonly because a scan you submitted is still being analyzed and has not yet reached a final state, or less commonly because a storage delete or the Stripe customer delete is slow or briefly failing — the reconciliation cron's sweepPendingDeletions (same cron this whole page describes above) retries the whole request every 5 minutes until it does — deletion is never silently abandoned partway through, and a scan still in progress is force-resolved (see "Abandoned / stuck scan" above) well before that becomes a real wait. You are signed out of your current browser session the moment you confirm, rather than waiting for it to expire on its own.
Deleted: your password and two-factor secret; every API key and signed-in session; every sign-in/verification code tied to your account — all revoked immediately, as the first step, described above. The original image bytes and thumbnails for every scan you made (purged from storage, same mechanism as the table above — deferred only for a scan still being analyzed, until it reaches a final state); the free-text notes AND the verdict/generator on any scan feedback you gave (the whole row is deleted, not just the notes); and your Stripe customer record (removed via the Stripe API — see the note below). Your account row itself is not deleted — it is anonymised in place: deleted_at is set, your email is replaced with a non-routable placeholder, and your name and profile image are cleared. Your real email address is freed by this — it can be used to register a new, unrelated account afterward, and nothing links the two.
Retained, anonymised: your scan history (status, verdict, confidence, and the full detection result) stays, with the filename and the image's content hash overwritten so the row can no longer be matched back to the original file. Your purchases, credit ledger, and current balance are not anonymised by this process at all — see the "Purchases, credit ledger, and current balance" row above: those three tables intentionally have no link to the account row that changed, so they simply keep pointing at your (now-anonymised) account id, unaffected, as the financial record they exist to be. Security log entries (sign-in history, IP address, user agent) already tied to your account before deletion, plus one new entry recording that the deletion happened, are kept for the same 400 days as every other account's security log — see "Audit log entries" above — still linked to your (anonymised) id for that whole window.
Stripe, honestly: deleting your Stripe customer record removes it from our records and from Stripe's own live dashboard and API. It does not purge whatever Stripe itself separately retains under its own data-retention obligations — invoices, charges, and payment-method records tied to a closed customer are commonly kept by payment processors independent of what their API exposes. We ask Stripe to remove its copy; we cannot make a stronger claim than that about what Stripe keeps afterward.
For anything account settings does not expose, contact privacy@resonixlabs.io.
Questions
Questions about this page or a specific retention window: privacy@resonixlabs.io.