Retention & Deletion
The File Storage service provides a two-phase deletion model: soft delete (recoverable) and permanent delete (irreversible), plus automatic orphan detection for files that lose all references.
Soft Delete
Soft delete moves a file to the trash. The file is no longer listed in normal queries but can be restored within 30 days.
DELETE https://api.septemcore.com/v1/files/01j9paf1l000000000000000
Authorization: Bearer <access_token>
Response 204 No Content. The file record in PostgreSQL is updated:
{ "status": "deleted", "deletedAt": "2026-04-15T10:30:00.000Z" }
Effects of soft delete:
GET /files/:idreturns410 Gone- File is excluded from
GET /fileslist by default - Storage bytes continue to count against the tenant quota
- File is recoverable via
POST /files/:id/restorefor 30 days
await kernel.files().delete('01j9paf1l000000000000000');
// file.status → 'deleted'
Restore from Trash
POST https://api.septemcore.com/v1/files/01j9paf1l000000000000000/restore
Authorization: Bearer <access_token>
Response 200 OK:
{
"fileId": "01j9paf1l000000000000000",
"status": "available",
"message": "File restored successfully."
}
await kernel.files().restore('01j9paf1l000000000000000');
// file.status → 'available'
Restore is only possible within 30 days of soft deletion. After that, automatic permanent deletion removes the file from S3.
Permanent Delete
Permanent delete removes the file from S3 immediately and irreversibly. Storage quota is updated within seconds.
DELETE https://api.septemcore.com/v1/files/01j9paf1l000000000000000/permanent
Authorization: Bearer <access_token>
Response 204 No Content. The file binary is removed from S3.
The PostgreSQL record is retained for audit purposes (status:
destroyed) but the URL returns 410 Gone permanently.
await kernel.files().deletePermanent('01j9paf1l000000000000000');
// file removed from S3, quota updated
| Action | Storage freed | Billing recalculated | Recoverable |
|---|---|---|---|
| Soft delete | No — file still in S3 | No | Yes — 30 days |
| Auto-delete after 30 days | Yes | Yes | No |
| Permanent delete | Yes — immediately | Yes — immediately | No |
Env override: FILES_SOFT_DELETE_RETENTION_DAYS=30 overrides the
30-day window. Billing recalculates storage_bytes after physical
removal.
List Deleted Files
To see files in trash:
GET https://api.septemcore.com/v1/files?status=deleted&limit=20
Authorization: Bearer <access_token>
{
"data": [
{
"fileId": "01j9paf1l000000000000000",
"filename": "old-contract.pdf",
"status": "deleted",
"deletedAt": "2026-04-10T08:00:00.000Z",
"restorableUntil": "2026-05-10T08:00:00.000Z"
}
],
"pagination": { "hasMore": false }
}
restorableUntil is deletedAt + FILES_SOFT_DELETE_RETENTION_DAYS.
Orphan Detection
An orphaned file is a file that has no foreign-key references from any table — for example, an avatar replaced by a new upload, a file whose parent record was deleted, or a module that was uninstalled.
Without orphan cleanup, these files occupy S3 storage indefinitely.
Detection Rules
| Rule | Value |
|---|---|
| Scan interval | Daily (FILES_ORPHAN_SCAN_INTERVAL_HOURS=24) |
| Grace period | 24 hours (FILES_ORPHAN_MIN_AGE_HOURS=24) |
| Delete after detection | 30 days |
| Audit record | files.orphan.deleted written to Audit Service |
Grace period rationale: A file is only eligible for orphan detection
if it has zero FK references and created_at < NOW() - 24h. The
24-hour grace prevents a false positive where the file was uploaded but
the module has not yet written the FK reference (e.g. the user uploaded
an avatar but the profile update transaction has not committed).
Detection Workflow
Daily background job:
Scan S3 object metadata
→ Check each fileId against PostgreSQL FK tables
→ No FK references found AND created_at < NOW() - 24h?
→ Mark file status: 'orphaned', orphanedAt: NOW()
→ 30 days later: DELETE from S3
WRITE audit record: files.orphan.deleted
Orphan vs Soft-Delete Retention
| Category | Hot storage | Cold storage | Physical delete after |
|---|---|---|---|
| Orphaned (no FK references) | 30 days | None | 30 days from detection |
| Soft-deleted (in trash) | 30 days | None | 30 days from deletion |
| Failed scan (rejected) | Until tenant removes | None | Manual only |
No cold storage tier for either category. 30 days is the enterprise standard (AWS, GCP: 30-day lifecycle policy for orphaned objects).
Bucket Layout
All tenant files are stored in a single S3 bucket, organized by
{tenantId}/{bucket}/ prefix. This maintains strict tenant isolation
while keeping S3 bucket counts manageable.
| Logical bucket | S3 prefix | Contents |
|---|---|---|
avatars | {tenantId}/avatars/ | User and org avatars |
assets | {tenantId}/assets/ | PWA assets, icons, branding |
documents | {tenantId}/documents/ | Reports, contracts, invoices |
exports | {tenantId}/exports/ | Data export archives (CSV, ZIP) |
modules | {tenantId}/modules/ | Module-specific file attachments |
staging | {tenantId}/staging/ | AV scan staging area (internal, not accessible) |
Tenant Prefix Example
S3 bucket: platform-files
Tenant A: platform-files/01j9p3kz5f.../avatars/01j9paf1l...
platform-files/01j9p3kz5f.../documents/01j9paf2m...
Tenant B: platform-files/01j9p4mk7g.../avatars/01j9paf3n...
platform-files/01j9p4mk7g.../exports/01j9paf4p...
Files from different tenants reside under separate prefixes. The File
Service's S3 IAM role policy grants access only to {tenantId}/* where
tenantId comes from the validated JWT — cross-tenant access is
architecturally prevented at the IAM level, not only at the application
level.
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| File not found | 404 | not-found |
| File already permanently deleted | 410 | FILE_DELETED |
| Restore window expired (> 30 days) | 409 | RESTORE_WINDOW_EXPIRED |
| File belongs to different tenant | 403 | forbidden |
| Restore on non-deleted file | 409 | FILE_NOT_DELETED |