Skoolsewa - Ecommerce Docs
Developer ResourcesBackup

Backup Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for backup.

Backup Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Backend/docs/developer/backup/backend, and directly: every service under apps/api/src/modules/backup/shared/, admin/*/, workers/*.ts, including workers/backup-restore.processor.ts and workers/restore-runner.tsService behavior, side effects, persistence, state machines, and exactly what the restore executor does and does not do.
API/docs/developer/backup/api, and directly: admin/*/*.controller.ts filesRoute surface, actors, auth, response-visible behavior.
Schemapackages/db/src/schema/backup/*.tsEnums, constraints, and the floors retention enforces.
Route ground truthapps/api/test/structure/structure.baseline.jsonConfirmed the 15 routes documented here: 14 admin, plus the public GET /api/system/maintenance.
Envapps/api/src/config/env.validation.tsDefaults for schedule, retention counts, timeouts, and the restore/replication/upload switches (BACKUP_ARTIFACT_HMAC_KEY_PATH, BACKUP_UPLOAD_MAX_MB, BACKUP_UPLOAD_MAX_RETAINED, INTERNAL_CLIENT_IP_TOKEN).

2. Feature Summary

FieldValue
Modulebackup
SubmoduleN/A
Primary user valueAn operator can prove the database is being protected, recover disk space automatically, get an off-site copy without babysitting it, catalogue an archive from another deployment, and genuinely put the database back the way it was after a mistake — with an automatic pre-restore safety dump and post-restore row-count verification, though without automatic linkage back to that safety dump yet (see 5.4). A customer/guest actor can also learn whether the store is currently offline.
ActorsSuperadmin (every admin route requires superadmin-level permissions), worker/system (cron schedulers, BullMQ processors, the detached restore-runner process), and guest/customer (the one public route).
Main entry pointsadmin/system/backups* (including .../upload) and admin/system/restores* HTTP routes; BackupScheduleScheduler's nightly cron; BackupRetentionScheduler's daily cron; BackupSweepScheduler's 5-minute cron.
Main outputsbackup_run/backup_restore rows, manifest.json/settings.json/restore-state.json/maintenance.json files, a streamed pg_dump download, an off-host rsync copy.
Related docsAPI, Backend.

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
SuperadminList/create/pin/prune/download backups; read and write backup settings; request a restore; poll live restore state; force-clear a stuck restoreAnything as a regular admin account — Backup, BackupDownload and BackupConfigure are all withheld from adminAdmin JWT + Backup_*/BackupDownload_READ/BackupConfigure_UPDATEThe only human actor this module recognises.
Admin (regular)Nothing directly in this module's backup surfacesEverything aboveSees 403 on every admin/system/backups* and admin/system/restores* route.
Admin (any, via maintenance)Read and toggle site-wide maintenanceAdmin JWT + System_READ/System_UPDATEMaintenance is gated on System_*, not a Backup_* permission, and not on Settings_* either — deliberately, so no content administrator can take the storefront offline or disengage maintenance mid-restore.
Worker/system (schedulers)Create a scheduled backup nightly; enqueue the daily retention sweep; fail stalled runs and stalled replications every 5 minutesSkip the same guards a human request would hit — the scheduled path shares BackupRunCreateService and BackupRetentionService with the admin-triggered pathIn-process cron (SchedulerRegistry/@Cron), no HTTP
Worker/system (BullMQ)Execute backup.run, backup.prune, backup.replicate, backup.restore (the last by taking an inline safety dump, then spawning and supervising the detached runner)Link the safety dump back to the restore it protects, or verify anything beyond row countsBullMQ worker (concurrency: 1 per queue)See 5.5 and 5.4 for what each queue actually does.
Restore runner (workers/restore-runner.ts)Fence the app DB role, run pg_restore --clean --single-transaction, verify row counts against the manifest at the verifying stage, heartbeat a state file, unfenceTake the safety dump itself (that is the processor's job, before the runner is even spawned), or verify anything beyond row countsDetached process (setsid), spawned by BackupRestoreProcessor on QueueName.BACKUP_RESTORESee 5.4.
Guest / customerCheck whether the store is currently in maintenance (GET /api/system/maintenance)Anything else — every other route in this module requires superadminNone — @Public()The one customer-facing route in this module; deliberately narrower than the admin maintenance DTO. See 5.7.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
List backupsAdminSuperadminGET admin/system/backupsbackup_runAPI 8.1
Run a manual backupAdminSuperadminPOST admin/system/backupssettings, disk free spacebackup_run, outbox_eventsAPI 8.2
Read backup settingsAdminSuperadminGET admin/system/backups/settingssettings.json, env, backup_run (health)API 8.3
Update backup settingsAdminSuperadminPUT admin/system/backups/settingscurrent settingssettings.json, possibly reloads the cron jobAPI 8.4
View backup detailAdminSuperadminGET admin/system/backups/{publicId}backup_runAPI 8.5
Pin / unpin a backupAdminSuperadminPATCH admin/system/backups/{publicId}backup_runbackup_run.pinnedAPI 8.6
Prune a backup nowAdminSuperadminDELETE admin/system/backups/{publicId}backup_run, backup_restore (floors)outbox_events, later backup_run + filesystemAPI 8.7
Download a backupAdminSuperadminGET admin/system/backups/{publicId}/downloadbackup_run, filesystemMongoDB AuditLog (must succeed)[API 8.8](/docs/developer/backup/api#88-get-apiadminsystembackupspublicid download)
Upload and catalogue an archiveAdminSuperadminPOST admin/system/backups/uploadsettings.json (retained cap), uploaded manifest/signaturebackup_run (kind:'uploaded')API 8.14
Request a restoreAdminSuperadminPOST admin/system/backups/{publicId}/restoreeverything preflight checksbackup_restore, restore-state.json, maintenance.json, outbox_events, and (inline, before the runner spawns) a new pre_restore_safety backup_runAPI 8.9
Poll live restore stateAdminSuperadminGET admin/system/restores/{publicId}/liverestore-state.json onlyAPI 8.10
Force-clear a stuck restoreAdminSuperadminPOST admin/system/restores/{publicId}/force-clearbackup_restorebackup_restore, maintenance.jsonAPI 8.11
Read maintenance stateAdminAny adminGET admin/system/maintenancemaintenance.json/Redis/cacheAPI 8.12
Toggle maintenanceAdminAny admin (with System_UPDATE)PUT admin/system/maintenancemaintenance.json, Redis mirrorAPI 8.13
Check maintenance status (public)PublicGuest/customerGET system/maintenancemaintenance.json/Redis/cacheAPI 8.15
Download a backup's uploaded filesAdminSuperadminGET admin/system/backups/{publicId}/download/uploadsbackup_run, filesystemMongoDB AuditLog (backup.download_uploads, must succeed)API 8.23
Read the key custody stateAdminSuperadminGET admin/system/backup-keykey file, backup_run (counts)API 8.16
Generate the signing keyAdminSuperadmin + passwordPOST admin/system/backup-key/generatekey filekey fileAPI 8.17
Import a key from another hostAdminSuperadmin + passwordPOST admin/system/backup-key/importkey filekey file (retired set)API 8.18
Rotate the signing keyAdminSuperadmin + passwordPOST admin/system/backup-key/rotatekey filekey file (old key → retired)API 8.19
Resolve backup signaturesAdminSuperadminPOST admin/system/backup-key/resolve-signatureskey file, artifact.sig on diskbackup_run.signature_state, .signing_key_fingerprintAPI 8.20
Prune a retired keyAdminSuperadmin + passwordDELETE admin/system/backup-key/retired/{fingerprint}key file, backup_run countskey fileAPI 8.21
Create a scheduled backupWorkerSystemNightly cron (BackupScheduleScheduler)settings, backup_run (tier resolution)backup_run, outbox_eventsBackend 7.2
Sweep retentionWorkerSystemDaily cron (BackupRetentionScheduler) → backup.prune {reason:"scheduled"}settings, backup_run per bucketfilesystem (rm), backup_runBackend 7.3
Recover stalled runs/replicationsWorkerSystem5-minute cron (BackupSweepScheduler)backup_run (running/pending)backup_run (→ failed)Backend 7.4
Replicate off-hostWorkerSystembackup.replicate job (enqueued at run completion)backup_run, filesystembackup_run replication columns, remote hostBackend 7.9

5. User-Facing Flows

5.1 Take a backup — manual ("Run now")

Summary

An operator wants a backup right now — before a risky migration, before a release, or just to confirm the feature works. They click "Run now" and see the row move from queued to running to completed on the list.

Preconditions

  • backupEnabled is true in settings (defaults true).
  • No other backup is currently queued or running.
  • Free disk space is at least minFreeDiskMb plus 1.2× the last completed dump's size (or just minFreeDiskMb for the very first backup ever taken).
  • Superadmin session with Backup_CREATE.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST admin/system/backups, optionally with includeUploads/note201, row queuedBackupAdminController.create
2BackendValidates enabled/no-run-in-progress/disk space, inserts the row and an outbox event in one transactionRow exists, job scheduledBackupRunCreateService.createRun
3WorkerOutbox dispatcher relays to QueueName.BACKUP (~5s), BackupRunHandler claims the rowRow runningBackupRunHandler.claim
4WorkerOpens a REPEATABLE READ snapshot, runs pg_dump --format=custom --snapshot=<id>, optionally tars the upload rootTwo artifact files on disk (or one, if uploads excluded)BackupManifestService.captureAndRun, BackupArtifactService
5WorkerWrites manifest.json before marking the row completeCatalog is durable on disk even if the process dies right afterBackupManifestService.writeManifestFile
6WorkerUpdates the row to completed with bytes/sha256/manifest; enqueues backup.replicate in the same transaction if configuredRow completed, appears on the listBackupRunHandler.run

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Backups disabledbackupEnabled=falseReject before any row is created503 BACKUP_DISABLED
Another run in progressA row is queued or runningReject synchronously — a second click is refused even before the first has been claimed by a worker409 BACKUP_ALREADY_RUNNING
Low diskFree space below the computed floorReject synchronously503 BACKUP_INSUFFICIENT_DISK_SPACE
pg_dump fails or times outTransient DB error, disk full, or exceeds dumpTimeoutMinutesRow marked failed; only a sanitised excerpt is stored, never raw stderrBACKUP_DUMP_FAILED / BACKUP_DUMP_TIMED_OUT
API crashes between artifact write and row completionProcess killed mid-dumpmanifest.json may already exist with no matching rowreconcileFromDisk (runs at boot) rebuilds the row from the manifest — this is the exact reason the manifest is written before the row is marked complete
API crashes with the row stuck runningWorker process dies without a clean failureRow sits running indefinitelyBackupSweepScheduler marks it failed within dumpTimeoutMinutes + 5 minutes

5.2 Take a backup — scheduled (nightly)

Summary

No human is involved. Every night at scheduleCron/scheduleTimezone (default 0 2 * * * Asia/Kathmandu), the system decides which grandfather-father-son (GFS) tier is due and creates that backup automatically.

Preconditions

  • backupEnabled is true.
  • Same disk-space and no-run-in-progress checks as the manual path — the scheduler calls the identical BackupRunCreateService.createRun.

Main Flow

StepActor/SystemActionResultSource
1SchedulerCron ticks in scheduleTimezoneBackupScheduleScheduler (SchedulerRegistry, not @Cron, because the schedule is admin-editable)
2SchedulerResolves the tier: the most significant period with no completed run yet — month, then ISO week, then daydaily/weekly/monthly chosenresolveTier / computeBackupTierPeriodBoundaries
3SchedulerCalls the same createRun({kind:"scheduled", tier}) the manual path usesRow queued, kind='scheduled'shared with 5.1 from here

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Tier already has a completed run this periode.g. a monthly already completed this calendar monthFalls through to the next-most-significant period still dueweekly, then daily
Every period already coveredRare — would mean a daily also already ran todayresolveTier always returns at least daily as the floor; a second daily in one day is only possible via a retried tick after a failure
Tick throwsAny error in runTickLogged and swallowedThe next night's tick is unaffected
Backups disabled at tick timebackupEnabled=falseSame as 5.1's disabled branch, caught by runTick's try/catchLogged, no row created, no exception propagates
Tier assigned before success is knownThe row is inserted queued with a tier already setThis is deliberate — chk_backup_run_tier_matches_kind requires a non-null tier on every scheduled row at INSERT, before the dump has even startedA failed scheduled run still "used" its tier slot for that tick; the next tick reassigns it since a failed run does not count as completed

5.3 Retention pruning

Summary

Old backups disappear on their own, keeping disk usage bounded, while always leaving enough history to recover from. An operator never manually deletes backups to manage disk space unless they want to remove one specific run early.

Preconditions

  • At least one bucket (daily/weekly/monthly/manual/pre_restore_safety) has more completed, unpinned rows than its retain* count.

Main Flow — scheduled sweep

StepActor/SystemActionResultSource
1SchedulerDaily at 03:00, enqueues backup.prune {reason:"scheduled"} directly (the documented outbox exemption — a cron tick with no accompanying database write)Job on QueueName.BACKUPBackupRetentionScheduler.enqueueSweep
2WorkerFor each of the 5 buckets, reads completed+unpinned rows newest-first, slices off everything past the retain countCandidate list per bucketBackupRetentionService.sweep
3WorkerFor each candidate, re-checks the 3 floors, then deletes the artifact directory and marks the row prunedDisk reclaimed; row survives as historyassertPrunable, pruneRun
4WorkerSeparately, for any remote_only run whose replication is verified and is not the overall-newest backup, deletes only the heavy local artifacts (keeps manifest.json)localArtifactPresent=false, row stays completedsweepRemoteOnlyLocalCopies

Main Flow — manual delete

StepActor/SystemActionResultSource
1AdminDELETE admin/system/backups/{publicId}Floors checked synchronously; 409 immediately if ineligibleBackupAdminService.remove
2BackendEnqueues backup.prune {reason:"manual", backupRunPublicId} through the outboxsame
3WorkerRe-checks the floors (they can change between click and pickup) then prunes exactly like the scheduled pathRow prunedBackupPruneHandler.prune

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Pinned row is a candidatepinned=trueSkipped, logged (scheduled) or 409 (manual)BACKUP_PINNED_CANNOT_DELETE
Row is the newest in its own buckete.g. the only daily row todayProtected regardless of the retain countBACKUP_LAST_REMAINING_CANNOT_DELETE
Row is the newest completed backup overallAcross every bucketProtected — the site must always have at least one restorable backupsame code
Row is the safety dump of the most recent restorekind='pre_restore_safety' and matches the latest backup_restore.safety_backup_run_idProtected — BackupRestoreProcessor.takeSafetyDump writes safety_backup_run_id onto the restore's own row immediately after the dump completes, so this comparison is genuinely satisfiable (see 10)same code
One floor blocks one row during a sweepAny of the above, during the scheduled sweepThat row is skipped and logged; the rest of the sweep continuesNever stops the whole sweep
A remote_only run is the overall-newestWould otherwise have its local copy reclaimedLocal copy is kept regardless — a restore that has to pull gigabytes over SSH during an incident is a restore that will not happen
Retried delete requestremove() called twice for an already-pruned rowpruneRun is a no-op for a non-completed rowIdempotent, not an error

5.4 Restore the live database

Summary

An operator needs to undo something catastrophic — replace the entire live database with a known-good backup. This is the single most irreversible operation in the product, and it is the only flow in this module gated by two independent switches: a grantable permission and a deploy-time environment variable that cannot be granted from inside the running application.

Read this before anything else in this section: requesting a restore genuinely replaces the database — preflight validates everything, maintenance engages, an automatic pre-restore safety dump is taken, linked to this specific restore, and must succeed before anything destructive runs, existing connections are drained before being terminated, a detached process runs pg_restore --clean --single-transaction against the confirmed dump, and row counts are verified against the manifest afterward. What still is not automated: verification beyond row counts (content, permissions), and a one-click "undo" that automatically resubmits the linked safety dump. See 10 below for exactly what is and is not covered.

Preconditions

  • BACKUP_RESTORE_ENABLED=true at the deploy level (default false — restore is meant to be armed deliberately, after an operator has already seen a backup complete and downloaded one, never on day one).
  • BACKUP_ADMIN_DATABASE_URL configured — a separate database role from the application's own DATABASE_URL, one permitted to terminate backends and drop objects. The web application's own credential must never carry that authority, so that a SQL injection anywhere in the API cannot inherit it.
  • The application's own DB role (DATABASE_URL's role) must not be a Postgres superuser. This is checked at preflight, before any write: the restore fences the application out with ALTER ROLE <app> CONNECTION LIMIT 0 so pg_restore --clean is not contending for ACCESS EXCLUSIVE locks against live traffic — and PostgreSQL does not enforce rolconnlimit against a role with rolsuper. If the application ever connected as a superuser, the fence would silently do nothing.
  • Superadmin session with Backup_RESTORE.
  • The source backup is completed with its artifact still present locally, its recorded size and sha256 matching the file on disk, and its schemaMigrationTag matching the currently running application's schema exactly (no override — a mismatch means the running code and the restored schema disagree, which surfaces later as a missing column somewhere unrelated).
  • No other restore is non-terminal, and no backup dump is currently queued/running.

Main Flow

StepActor/SystemActionResultSource
1AdminTypes the live database name, checks both "I understand" boxes, submits POST .../restoreAdmin UI + RestoreBackupDto
2BackendRuns 8 sequential preflight guards (feature flag, superuser check, source exists, acknowledgements, typed name matches current_database(), source restorable, schema tag matches, size+sha256 match on disk)All pass, or the whole request is refused with nothing changedBackupRestorePreflightService.assertRestoreAllowed
3BackendInserts a backup_restore row (queued) and an outbox event, one transactionRow existsBackupRestoreAdminService.requestRestore
4BackendWrites restore-state.json (stage:"queued", including sourceArtifactDir the runner will need)The live-poll endpoint has something to read immediately, and the runner has everything it needs once it startsBackupStateFileService.writeRestore
5BackendEngages maintenance immediately, not deferred to a workerCustomer traffic is refused from this instant, closing the window where writes would otherwise be destroyed on the eventual restoreMaintenanceService.engage
6BackendReturns 202 RestoreLiveStateDtoAdmin sees "Restore accepted"
7WorkerOutbox dispatcher relays backup.restore to QueueName.BACKUP_RESTORE; BackupRestoreProcessor confirms the state file matches this restore, reads the effective settings once, then — before spawning anything — takes an inline, awaited pre-restore safety dump: a real kind:'pre_restore_safety' backup, run synchronously in the same process via BackupRunHandler.run(), not enqueued. Its id is written into the state file as safetyBackupPublicId before the dump runsState file moves to stage:"safety_dump"; if the dump does not reach completed, the restore aborts here and maintenance stays engagedworkers/backup-restore.processor.ts (takeSafetyDump)
8WorkerOnce the safety dump is confirmed completed, links it to the restore: UPDATE backup_restore SET safety_backup_run_id. If that update matches no row, the restore aborts here too, rather than proceeding with a link it could never recordbackup_restore.safety_backup_run_id setworkers/backup-restore.processor.ts (takeSafetyDump)
9WorkerSpawns restore-runner.ts <restorePublicId> as a detached process — deliberately not an in-process BullMQ worker, because pg_restore --clean holds ACCESS EXCLUSIVE on every table including admin_sessions, which JwtStrategy reads on every authenticated request — with the effective restoreTimeoutMinutes/restoreDrainSeconds injected into its environmentState file advances drainingrestoringverifyingcompleted/failed (reconciling remains declared but never assigned)workers/backup-restore.processor.ts (spawnRunner)
10RunnerFences the application's DB role (ALTER ROLE ... CONNECTION LIMIT 0), then drains: polls open connections for that role once a second, heartbeating progress, until either none remain or restoreDrainSeconds elapses — only then terminates whatever is left with pg_terminate_backendNew connections blocked immediately; existing ones get a grace window to finish in-flight work before being cutworkers/restore-runner.ts
11RunnerRuns pg_restore --clean --if-exists --single-transaction --no-owner --no-privileges against the confirmed dump, bounded by restoreTimeoutMinutesDatabase replaced, or left untouched on failureworkers/restore-runner.ts
12RunnerMoves to stage:"verifying" and runs verifyRowCounts(): reads the source backup's own manifest.json and compares every listed table's recorded row count against a live count(*) on the just-restored database, excluding RESTORE_VERIFICATION_EXCLUDED_TABLES (session tables, backup_run, backup_restore, outbox_events, drizzle_migrations — tables the restore and the safety dump themselves are expected to change)Any mismatch throws, reported as BACKUP_RESTORE_VERIFICATION_FAILED specifically (not the generic failure code)workers/restore-runner.ts (verifyRowCounts)
11RunnerHeartbeats the state file every 5 seconds throughout, then always releases the connection fence in a finally block, releasing it before publishing stage:"completed" so the supervisor's own DB query is not itself blocked by the fenceworkers/restore-runner.ts
13WorkerBackupRestoreProcessor reads the terminal stage, updates backup_restore if the row still exists (it usually does not — pg_restore --clean already replaced it with the dump's own pre-restore copy), and disengages maintenance on successCustomer traffic resumes; on failure, maintenance stays engaged for an operator to inspectBackupRestoreProcessor.finish/fail
Not builtVerifying anything beyond row counts (permissions, content) — the structured backup_restore.verification shape is never written even though the row-count comparison itself runs; and a one-click "undo" that automatically selects the linked safety dump as the new restore sourceA restore that reaches completed is trusted on row counts alone; undoing a bad restore still means an operator manually submitting the (now directly discoverable, via safetyBackupPublicId) safety dump as a fresh restore requestbackup-restore.ts, workers/restore-runner.ts

Why --single-transaction is load-bearing

Probed directly, not assumed: restoring a truncated dump with --single-transaction left the target database completely untouched — the whole restore rolled back as one unit. Restoring the same truncated dump without the flag emptied the table and the original rows were gone. The automatic pre-restore safety dump is a second, independent safety net on top of this — the way back if the chosen backup itself turns out to be wrong, not just if pg_restore fails partway. This is why --single-transaction is a fixed part of pg_restore's argv in the runner script, never a configurable option.

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Restore not armedBACKUP_RESTORE_ENABLED=false, or BACKUP_ADMIN_DATABASE_URL unsetRefused before any write503 BACKUP_RESTORE_DISABLED
App role is superuserrolsuper=true on the DATABASE_URL roleRefused — the connection fence would be a no-op409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER
Wrong database name typedDoes not match current_database()Refused — the confirmation dialog is a speed bump, this check is the actual control400 BACKUP_RESTORE_CONFIRMATION_MISMATCH
Schema version mismatch or unknownBackup's schemaMigrationTag differs from, or either side is missingRefused, no override409 BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH
Artifact tampered or corruptedSize or sha256 mismatch on diskRefused before any restore attempt409 BACKUP_SIZE_MISMATCH / BACKUP_CHECKSUM_MISMATCH
Another restore already in flightA backup_restore row is non-terminalRefused fast, before the partial unique index would surface a raw 23505409 BACKUP_RESTORE_ALREADY_ACTIVE
A backup dump is runningbackup_run queued/runningRefused — restoring while a snapshot transaction is open is not safe409 BACKUP_RESTORE_RUN_IN_PROGRESS
Pre-restore safety dump fails or does not reach completedDisk full, pg_dump error, or the dump insert itself throwsRestore aborts before pg_restore ever runs; maintenance stays engagedBACKUP_RESTORE_FAILED
pg_restore succeeds but row counts do not match the manifestCorruption, or an unexpected schema drift the tag check missedThe database is populated but wrong — treated as more dangerous than an outright failureBACKUP_RESTORE_VERIFICATION_FAILED
Runner never spawns, or dies mid-restorerestore-runner.ts fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDSBackupRestoreProcessor marks the row failed (BACKUP_RESTORE_ABANDONED), maintenance stays engagedOperator must inspect the server log
Restore accepted, then the operator needs to abandon it regardlessA runner that never spawned, was killed, or is genuinely still running and the operator wants out anywayMarks the row failed, disengages maintenance — but does not stop a still-running pg_restore subprocessPOST .../force-clear
Live-state poll during a restoreThe state file is being written concurrentlyThe endpoint reads the file directly and touches no table, so it stays answerable while pg_restore --clean holds ACCESS EXCLUSIVE locks and JwtStrategy would otherwise block reading admin_sessionsExercised by real restores now, not only designed for them

5.5 Off-host replication

Summary

An operator wants a copy of every backup somewhere other than the VPS the database lives on, so a lost or compromised host does not also mean a lost backup. This is opt-in, transport-hardened, and verified by reading the bytes back rather than trusting the transfer's own exit code.

Preconditions

  • BACKUP_REMOTE_ENABLED=true, BACKUP_REMOTE_TARGET (a user@host:/path), BACKUP_REMOTE_SSH_KEY_PATH and BACKUP_REMOTE_KNOWN_HOSTS_PATH all configured at deploy time.
  • remoteReplicationEnabled=true in settings, and storageMode set to both or remote_only.
  • At boot, BackupReplicationService refuses to start with replication enabled unless the configured rsync binary supports --secluded-args (rsync 3.2.3+) and the known-hosts file exists and is non-empty — both are asserted once, at startup, rather than discovered failing at 02:00.

Main Flow

StepActor/SystemActionResultSource
1WorkerOn dump completion, if storageMode !== 'local' and replication is enabled, enqueues backup.replicate in the same transaction that marks the run completedreplicationStatus='pending'BackupRunHandler.run
2WorkerRecomputes the run's relative artifact directory from publicId+createdAtnever trusts the artifact_dir column, which originates in an operator-editable manifest.json — and validates it against a fixed <yyyy>/<mm>/<uuid> patternRefuses before any transfer if the shape is wrongBackupReplicationService.replicate
3WorkerPushes the dump (and archive, if present) via rsync --secluded-args, excluding manifest.jsonHeavy artifacts land on the remote hostrsyncPush
4WorkerPulls the dump back to a local temp directory and hashes it locally — never trusts a remote-reported hash, and this is compatible with a write-only deploy key that cannot run remote commandsVerified byte-for-byteverifyByReadBack
5WorkerPushes manifest.json last, only once the heavy artifacts are verifiedA disaster-recovery-side reconcileFromDisk never sees a manifest naming a completed run whose dump has not fully arrivedrsyncPush (manifest-only pass)
6WorkerUpdates the row: replicationStatus='replicated', replicatedAt, remoteArtifactDirSettings' replication-health fields reflect success

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Already replicatedDuplicate delivery of the same jobNo-op — nothing to replicate, nothing to retry
No local artifact to replicateRun not completed, or already local-copy-reclaimedNo-op, logged
Recomputed path does not match the stored/expected shapeTampered or unexpected manifest.jsonRefused before any rsync callMarked failed
Transport failureNetwork unreachable, auth failure, timeoutRow marked replicationStatus='failed'the backup itself stays completed, since a dump that succeeded and could not be shipped off-box is still a usable backupBACKUP_REMOTE_UNREACHABLE, BACKUP_REPLICATION_FAILED
Remote disk fullrsync reports no spaceRow marked failed with a specific codeBACKUP_REMOTE_DISK_FULL
Read-back hash mismatchBytes arrived corrupted despite rsync reporting successTreated as a failure, same as a transport errorBACKUP_REPLICATION_VERIFY_FAILED
Replication stuck pendingA worker died mid-transferBackupSweepScheduler ages it to failed past BACKUP_REPLICATE_TIMEOUT_MINUTES + 5
storageMode='remote_only', retention sweep runsRun is verified-replicated and is not the overall-newest backupLocal heavy artifacts deleted, manifest.json kept, localArtifactPresent=falsereconcileFromDisk still works, since the manifest never left
Remote-side retentionOld copies piling up on the backup hostDeliberately out of scope for this module — the backup host is expected to run its own retention/rotation; this feature only ever pushes, never prunes remotely

5.6 Upload and catalogue an operator-supplied archive (SE-4)

Summary

An operator has a pg_dump archive from somewhere other than this deployment's own scheduled/manual dumps — moved from another environment, or pulled off a downed host — and wants it in the catalog so the existing restore flow can act on it. This never restores anything by itself; it only authenticates and files the archive away.

Preconditions

  • Superadmin session with BackupUpload_CREATE.
  • A current signing key held in the key file — see 5.8.
  • The archive, its manifest.json, and its artifact.sig were all produced by a deployment holding a key this one generated — the signature is the actual authentication, not the checksum inside the manifest. An imported key does not count here: on upload the caller supplies the archive, the signature and the key, which is three operands from one actor.
  • Fewer than BACKUP_UPLOAD_MAX_RETAINED completed uploaded backups already exist.

Main Flow

StepActor/SystemActionResultSource
1AdminSelects the three files (file, manifest, signature) and submits POST .../backups/uploadMultipart request sentAdmin UI + BackupUploadDto
2BackendStreams the dump to a .partial file, hashing as it writes; checks the retained cap; parses the manifest for schemaMigrationTag/databaseDumpSha256Corruption/shape checks pass, or refused immediatelyBackupUploadStorageEngine, BackupUploadAdminService
3BackendVerifies the HMAC signature over dumpSha256:manifestSha256 — the actual authentication controlProceeds only if the signature matchesBackupSignatureService.verify
4BackendFormat-checks the archive (pg_restore --list), renders its schema SQL, and scans it for constructs a restore must not executeRefused if a dangerous construct is foundscanArchiveSql
5BackendMoves the dump into a real artifact directory, writes a fresh, server-generated manifest and signature (never the operator's own manifest), inserts a backup_run row (kind:'uploaded', pinned:true, status:'completed')201, appears in the catalog immediately, restorable like any other completed backupBackupUploadAdminService.finalize

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
A required part is missingfile, manifest, or signature absentRefused before any processing400 BACKUP_ARCHIVE_UNREADABLE / BACKUP_UPLOAD_MANIFEST_INVALID / BACKUP_UPLOAD_SIGNATURE_INVALID
Manifest is malformed or missing required fieldsNot JSON, not an object, or missing schemaMigrationTag/databaseDumpSha256Refused400 BACKUP_UPLOAD_MANIFEST_INVALID
Transfer was truncated or corruptedDump's sha256 does not match the manifest's claimRefused before the signature is even checked409 BACKUP_CHECKSUM_MISMATCH
Signing not configured on this deploymentNo current key in the key fileRefused — fails closed rather than accepting an unauthenticated archive503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED
The key store is unreadableThe key file exists and cannot be parsedRefused. A corrupt file is not an absent one, and skipping verification because the file is broken is the failure the branch exists to prevent422 BACKUP_KEY_STORE_UNREADABLE
Wrong key, or archive from an unrelated deploymentHMAC does not matchRefused409 BACKUP_UPLOAD_SIGNATURE_INVALID
Archive contains a refused SQL constructe.g. an unexpected SECURITY DEFINER functionRefused before cataloguing409 BACKUP_UPLOAD_TOC_REJECTED
Retained cap reachedBACKUP_UPLOAD_MAX_RETAINED completed uploads already existRefused409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED
Re-uploading an already-catalogued archiveOperator downloads a previously-uploaded row and re-uploads itSucceeds — the row is re-signed over its own fresh manifest each time, so a round trip does not break the signatureNew backup_run row, subject to the retained cap
Retention never touches an uploaded rowGFS sweep runskind:'uploaded' rows are excluded from RetentionBucket entirely, on top of being pinned:trueOnly an explicit admin DELETE removes one

5.7 Check maintenance status (customer)

Summary

A customer's browser (via the storefront's own request handling) needs to know, on every page it serves, whether the store is currently refusing traffic — uncached and without authentication. This is the one capability in the module a guest can use directly.

Preconditions

None. No auth, no permission, no feature flag.

Main Flow

StepActor/SystemActionResultSource
1StorefrontCalls GET /api/system/maintenance on (effectively) every request it serves, uncachedMaintenanceCustomerController.getStatus
2BackendReads the same 3-layer maintenance state (in-process cache → Redis → file) every admin-facing check uses{active, reason}MaintenanceCustomerService.getStatusMaintenanceService.getState
3BackendMaps onto the narrower public shape — engagedBy/engagedAt never leave this boundary200 MaintenanceStatusDtosame

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Maintenance not engagedactive: false in shared statereason forced to null regardless of any stale stored value{active:false, reason:null}
Maintenance engaged by a restorerestorePublicId set in shared stateNot exposed here — only active/reason cross the boundary{active:true, reason:"..."} (or null reason if the operator/restore did not set one)
Rate limit exceededMore than 300 requests/min from one IPRefused429
This route itself during maintenanceThe store is refusing every other routeStays answerable — it is named literally in MAINTENANCE_EXEMPT_PATHS200, same as always

5.7a Getting the uploaded files back

A backup taken with "Also archive uploaded files" produces two artifacts: database.dump + manifest.json + artifact.sig in one tar, and uploads.tar.gz beside them.

Download hands out the first three only. That is deliberate — they are exactly what the upload endpoint consumes, and folding a multi-gigabyte archive into that tar would break the round trip and make every routine dump download enormous.

What was missing is that nothing said so. The operator ticked the box, the panel reported the backup complete and showed the archive's size, and the download returned a file without it.

So there are two controls, and neither is labelled just "Download":

ControlGivesShown when
Download databasedatabase.dump, manifest.json, artifact.sigalways, with BackupDownload_READ
Download uploaded filesuploads.tar.gzthe run archived uploads, it completed, its artifacts are still on this host, and the operator holds the permission

That third condition is not padding. The retention sweep reclaims a backup to a remote copy by deleting the local files and setting local_artifact_present false — while leaving status as completed. Without checking it, the link renders on a swept run and answers 410; because the control is a plain link, the tab navigates away and shows raw JSON. A race can still reach that (the sweep can land between render and click), so the conditions make a refusal unlikely rather than impossible.

Verifying what you downloaded

The detail page shows Uploads archive sha256. Check the file you received:

sha256sum backup-<publicId>-uploads.tar.gz

The server deliberately does not hash the archive before sending it — see API 8.23. Hashing it first would read the whole file before the first header and turn every large download into a timeout, and nothing restores this archive, so there is no automated consumer to protect. The published digest is the control, and it verifies the bytes that actually arrived rather than the bytes that were on disk.

What the archive contains, and one thing to know about it

Everything under the upload root at dump time, as a gzipped tar. Two caveats:

  • The artifact HMAC covers the database dump and manifest only, never uploads.tar.gz. The signature badge says "Signed (database dump)" for exactly this reason.
  • The archive is taken from the local upload root regardless of the configured storage driver. On a deployment whose files live in object storage that directory may be empty or stale, and the run still records a size and a checksum. The rendered byte count next to the link is the operator's warning.

5.8 Custody of the artifact-signing key

The key that decides whether a backup archive can be verified — and therefore whether it can be restored — used to live inline in .env. It now lives in a file the application owns, with a lifecycle an operator can drive from the panel.

Rotation keeps the old key, and that is the whole design. A retired key is not a dead key: it is what still verifies every artifact signed before the rotation. Without a retired set, "rotate the signing key" would mean "make every existing backup unrestorable" — and an operator would discover that at the worst possible moment.

Reveal-once. generate and rotate return the key material in their response and nothing can retrieve it afterwards. The panel shows it in a dialog whose confirm button is disabled until the operator ticks "I have stored this key somewhere outside this server". If the value is lost, the recovery is to rotate again — not to read it back.

The migration never generates a fresh key. It copies the existing .env value byte for byte, because every artifact already on disk is signed with it. A fresh key would make them all unverifiable, and the symptom — BACKUP_UPLOAD_SIGNATURE_INVALID — reads like corruption rather than like a key change, which is the worst possible way for that to present.

Pruning a retired key, and the two refusals

Deleting a retired key is irreversible: the only way back is to import the same material again, if the operator still has it. Two independent checks refuse it, and force overrides both.

RefusalReadsWhy it is not the same check twice
Completed backups still carry this fingerprintbackup_run.signing_key_fingerprintThose artifacts would become unverifiable.
Some completed run is still unresolvedsignature_state IS NULL OR = 'signed_unknown_key'An unresolved run might be one this key alone can verify, and the count above cannot see it. Run "Resolve backup signatures" first.

Neither count can see an archive kept off this host — it has no row here. So a 0 never means "nothing depends on this key", and the panel says so in the same sentence as the number. That is also exactly the population import exists for.

Resolving signatures

resolve-signatures walks completed runs, reads each artifact's artifact.sig, and records which held key verifies it. It is batched: call again while remaining is above zero.

It re-examines rows already resolved as signed_unknown_key, because importing a key changes that answer. signed_unknown_key is a cache, not a fact — which is why every predicate that selects work reads signature_state IS NULL OR signature_state = 'signed_unknown_key', never IS NULL alone. A predicate that checked only NULL would resolve a row once, cache "no key held", and never look again, so importing the very key that verifies it would change nothing.

5.9 Who did this, from where

The admin panel is a BFF: the browser calls the Next server, and that server calls the API. So the API's req.ip was the panel process, and every admin action in the activity log read 127.0.0.1 — literally accurate, and useless for a log whose entire purpose is telling operators apart.

Three decisions worth stating, because each has an obvious wrong answer:

  • Read from the right, never split(",")[0]. Under nginx's standard $proxy_add_x_forwarded_for the leftmost entry is whatever the client sent — fully attacker-controlled. Counting in from the right, past the hops actually operated, is the only reading a caller cannot forge.
  • TRUSTED_PROXY_HOPS=0 means "derive nothing", and it is the default. With one trusted hop and a single-entry inbound header — every local run, and any deployment where nothing in front rewrites x-forwarded-for — the entry counted from the right is the browser's own value. Forwarding that under a valid token would hand the caller the address the audit log records, inverting the whole exercise. A deployment must say it has an address-rewriting proxy.
  • The address is not a rate-limit input. It reaches the activity log and nothing else. Feeding a client-influenced address into IpThrottlerGuard would let an unauthenticated attacker choose their own bucket on the admin credential path.

Until a deployment configures both halves, the log records the loopback address — which is merely useless, rather than wrong. That is the deliberate failure mode: an audit entry that says nothing beats one that says something false.

6. Admin Flows

6.1 Create

See 5.1POST admin/system/backups. Route: POST admin/system/backups. Permission: Backup_CREATE. Mutation side effects: backup_run insert, outbox enqueue. Cache invalidation: none (this module has no cached list reads). Audit: backup.create. Failure branches: 503 BACKUP_DISABLED, 409 BACKUP_ALREADY_RUNNING, 503 BACKUP_INSUFFICIENT_DISK_SPACE.

6.2 List / Read Detail

GET admin/system/backups and GET admin/system/backups/{publicId}. Permission: Backup_READ. No side effects. Failure: 404 BACKUP_NOT_FOUND on detail.

6.3 Update (pin/unpin)

PATCH admin/system/backups/{publicId} with {pinned: boolean}. Permission: Backup_UPDATE. Mutation: backup_run.pinned. Audit: backup.pin/backup.unpin with a changes[] diff. Failure: 404 BACKUP_NOT_FOUND.

6.4 Soft delete (prune)

DELETE admin/system/backups/{publicId} — see 5.3. Not a true soft delete in the usual sense: the row is never restorable back to completed, but it survives as a pruned audit record rather than being hard-deleted. Failure: 404, or 409 on any of the 3 floors.

6.5 Restore

See 5.4 in full. Route: POST admin/system/backups/{publicId}/restore. Permission: Backup_RESTORE, plus BACKUP_RESTORE_ENABLED. Takes an inline pre-restore safety dump, genuinely replaces the database via restore-runner.ts, and verifies row counts against the manifest afterward; ends at completed or failed (BACKUP_RESTORE_FAILED or, specifically for a row-count mismatch, BACKUP_RESTORE_VERIFICATION_FAILED), or at a manually force-cleared failed if the safety dump or the runner never completes.

6.6 Force-clear

POST admin/system/restores/{publicId}/force-clear — see 5.4's edge cases. Permission: Backup_RESTORE. Mutation: backup_restore.status='failed', disengages maintenance. Does not stop a genuinely running pg_restore subprocess.

6.7 Settings (configure)

GET/PUT admin/system/backups/settings, backing a dedicated admin panel settings page — see Section 12.3 for the field-by-field editable/environment-only split. Permission: Backup_READ (get) / BackupConfigure_UPDATE (put) — a third, separate permission module from Backup_*, because authorising dump/pin/prune/restore is not the same act as authorising a change to the off-host replication destination. PUT is a full-replace write keyed by an optimistic version — a stale read is 409 BACKUP_SETTINGS_CONFLICT, not a silent overwrite.

6.8 Maintenance (supporting flow, not its own feature)

GET/PUT admin/system/maintenance. Permission: System_READ/System_UPDATE. Exists to support the restore flow (engaged automatically at restore request) and to give operators a manual kill switch for other purposes (a risky migration, an incident). See the state diagram in Section 7.2.

6.9 Upload (catalogue)

See 5.6 in full. Route: POST admin/system/backups/upload. Permission: BackupUpload_CREATE — a fourth, separate permission module from Backup_*/BackupDownload_*/BackupConfigure_*, because introducing a database from outside is not the same act as running a dump of this one. Synchronous — 201 means the row is already catalogued, not queued.

6.10 Key custody

See 5.8 in full. Routes: GET admin/system/backup-key plus generate / import / rotate / resolve-signatures and DELETE .../retired/{fingerprint}. Permission: BackupKey_* — a fifth, separate permission module, because replacing the credential every artifact is authenticated against is not the same act as changing how often a dump runs. Audit: backup.key.generate / .import / .rotate / .prune. Every mutating route additionally re-checks the operator's password.

Failure branches: 400 BACKUP_KEY_REAUTH_REQUIRED, 401 BACKUP_KEY_REAUTH_FAILED, 409 BACKUP_KEY_NO_PASSWORD_SET, 409 BACKUP_KEY_ALREADY_EXISTS, 409 BACKUP_KEY_FINGERPRINT_UNKNOWN, 409 BACKUP_KEY_STILL_IN_USE, 422 BACKUP_KEY_PATH_NOT_CONFIGURED, 422 BACKUP_KEY_INVALID_MATERIAL, 422 BACKUP_KEY_STORE_UNREADABLE.

7. Lifecycle and State Transitions

7.1 backup_run.status

EntityFromEvent/ActionToGuard ConditionSide Effects
backup_runPOST .../backups or nightly cronqueuedEnabled, no run in progress, disk space sufficientOutbox event enqueued
backup_runqueuedWorker claims the jobrunningstatus IN ('queued','running') — retries can reclaimstarted_at set
backup_runrunningpg_dump (and optional tar) succeed, manifest writtencompletedBytes/sha256/manifest recorded; replication enqueued if configured
backup_runrunningpg_dump/tar fail or time outfailedSanitised error excerpt recorded
backup_runrunningNo heartbeat within dumpTimeoutMinutes + 5failedBackupSweepSchedulerBACKUP_DUMP_TIMED_OUT
backup_runcompletedRetention sweep or manual delete, floors passprunedNot pinned, not newest-overall, not newest-in-bucket, not most-recent-safety-dumpArtifacts deleted, artifact_dir=null, row survives

7.1a backup_run.signature_state

FromEvent/ActionToGuard ConditionSide Effects
(NULL)Run completes with a current key heldsignedsigning_key_fingerprint set in the same statement
(NULL)Run completes with no key heldunsigned!isStoreUnreadable()Permanent for that artifact
(NULL)Run completes while the key store is unreadable(stays NULL)Deliberate: the answer is not known yet, and unsigned would be a permanent lie
(NULL)resolve-signatures finds a matching held keysignedartifact readable on this hostFingerprint recorded
(NULL)resolve-signatures finds no artifact.sigunsignedartifact readable on this host
(NULL)resolve-signatures finds a signature matching nothing heldsigned_unknown_keyartifact readable on this hostFingerprint stays NULL
signed_unknown_keyimport adds the matching key, then resolve-signaturessignedThis transition is why the state is a cache, not a fact
signedanythingsignedNever overwritten; a resolved row is final

The only transition into signed_unknown_key is "no held key matched at that moment", and the only way out is importing a key. A resolution predicate that read signature_state IS NULL alone would resolve each row once, cache "no key held", and never revisit it — so importing the very key that verifies an archive would change nothing, and the prune refusal built on those counts would be built on a stale answer. Every predicate therefore reads signature_state IS NULL OR signature_state = 'signed_unknown_key'.

The three CHECK constraints that hold the shape (signed implies a fingerprint, unsigned implies none, any state implies status = 'completed') are written as implications with IS DISTINCT FROM, never as a closed disjunction over the enum — a CHECK passes on NULL, and a future enum value must not silently fall into the wrong branch.

7.2 Maintenance state (supporting)

7.3 backup_restore.status and the live-state file's stage — two different state machines

The table column only ever holds queued or a terminal value, by design — pg_restore --clean replaces the table holding its own row mid-restore, so intermediate stages cannot live there:

The state file's stage field carries the real intermediate lifecycle (accepted → queued → draining → safety_dump → restoring → verifying → reconciling → completed/failed). safety_dump is written by BackupRestoreProcessor.takeSafetyDump; draining, restoring, verifying, completed and failed are written by restore-runner.ts as it fences, runs pg_restore, and checks row counts against the manifest. Only reconciling remains declared but never reached by any current code path. See 5.4.

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Manual/scheduled backupbackup_run insert then update; outbox_eventsNonebackup.run (→ maybe backup.replicate)NoneNoneNone
Retention pruningbackup_run update (pruned); outbox_events (manual path)Nonebackup.pruneNoneNoneNone
DownloadNone (read-only)NoneNoneNoneNoneMongoDB AuditLog write (not fire-and-forget)
Restore requestbackup_restore insert then update (terminal status); outbox_events; restore-state.json; maintenance.json; plus an inline backup_run insert+update for the pre-restore safety dump (kind:'pre_restore_safety')Maintenance Redis mirror invalidated/rewrittenbackup.restore (consumed by BackupRestoreProcessor, which takes the safety dump then spawns restore-runner.ts)NoneNoneNone
Force-clearbackup_restore update (failed); maintenance.jsonMaintenance Redis mirror rewrittenNoneNoneNoneNone
Replicationbackup_run update (replication columns)Nonebackup.replicateNoneNoneNone
Upload and cataloguebackup_run insert (kind:'uploaded'); filesystem (dump, fresh manifest + signature)NoneNone (synchronous)NoneNoneNone
Maintenance toggleNonemaintenance.json, Redis mirror, in-process 1s cacheNoneNoneNoneNone
Maintenance status check (public)None (read-only)NoneNoneNoneNoneNone

10. Error and Recovery Flows

10a. What the operator sees when a download is refused

Both download controls are plain <a href>. They have to be: the response is a stream, and the access token is an httpOnly cookie the browser cannot read, so the BFF route beside the page is what carries it. The consequence was that any non-2xx navigated the operator out of the dashboard and rendered the API's error envelope as raw JSON.

The BFF now answers a failed navigation with a 302 back to the page the operator started from, carrying ?downloadError=<code>. An inline notice renders the mapped message and the parameter is removed from the URL, so a refresh or a shared link cannot replay someone else's error.

Where the operator clickedWhere a refusal returns them
the list (?from=list on the link)the list
a backup's detail pagethat detail page

Three things about this are deliberate:

  • A non-navigation still gets JSON. Only a request carrying Sec-Fetch-Mode: navigate is redirected, so the route keeps an API-shaped contract for a same-origin fetch().
  • A CSRF refusal is never redirected. A 302 into the dashboard would erase the signal that the guard fired, and the guard deliberately answers before the authentication check so a cross-site probe cannot tell a signed-in operator from a signed-out one.
  • The message is looked up, never rendered from the URL. downloadError is attacker-supplied by construction — anyone can craft the link and send it. It only ever selects from a fixed map, and an unrecognised value gets a generic message. Rendering it would let whoever wrote the URL choose what the operator reads.

The obvious alternative — fetch the response in a click handler and show the error — was rejected: it means buffering the success case in the tab, and these artifacts are multi-gigabyte by design. That trades a bad error surface for an out-of-memory crash on the path that works.

ScenarioTriggerUser/System ExperienceRecoverySource
Dump fails mid-runpg_dump/tar subprocess errorRow failed; admin sees a sanitised error messageTrigger another manual run; investigate the server log for the raw errorBackupRunHandler.markFailed
API crashes right after a dump finishesKill between manifest.json write and row completionA completed artifact exists with no catalog rowreconcileFromDisk (boot, or on demand) rebuilds it from the manifestBackupManifestService.reconcileFromDisk
API crashes mid-dumpKill before completionRow stuck runningBackupSweepScheduler marks it failed within ~35 minutes by defaultbackup-sweep.scheduler.ts
Replication transfer failsNetwork, auth, disk-full on the remoteBackup stays completed; replicationStatus='failed'; visible via replicationFailureStreak in settingsInvestigate connectivity/credentials; the next run's replication attempt is independentBackupReplicationService.replicate
Replication stuck pendingWorker died mid-transferRow never resolvesBackupSweepScheduler ages it to failedsweepStalledReplications
Rate limit exceededToo many requests in the window429Wait for the window to reset (per-route budgets in Section 4)IpThrottlerGuard
Pre-restore safety dump fails, or does not reach completedDisk full, pg_dump error, or the safety-dump insert itself throwsRestore aborts before pg_restore ever runs; maintenance stays engaged; backup_restore marked failed (BACKUP_RESTORE_FAILED)Investigate the server log for the safety dump's own error; free disk or fix the underlying issue and resubmit the restoreBackupRestoreProcessor.takeSafetyDump
Restore runner never spawns, or dies mid-restoreProcess fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDSMaintenance stays engaged; backup_restore marked failedPOST .../force-clear if the row is not already marked; investigate the server log for the runner's own error before retryingSee 5.4
pg_restore succeeds, but restored row counts do not match the manifestCorruption, or a schema/data drift the earlier checks missedMaintenance stays engaged; backup_restore marked failed with BACKUP_RESTORE_VERIFICATION_FAILED specifically — distinct from the generic failure code, because the database is populated but wrongThe pre-restore safety dump is already in the catalog; an operator investigates the mismatch, then either accepts the state or restores the safety dump as a new, ordinary restoreworkers/restore-runner.ts (verifyRowCounts)
A restore that ran needs to be undoneOperator restored the wrong backup, or a restore's row-count verification failedA pre_restore_safety dump was genuinely taken, is in the catalog, and is linked to this specific restore (backup_restore.safety_backup_run_id)Recovery is still manual, but directly addressable: read safetyBackupPublicId off RestoreLiveStateDto (populated from stage:"safety_dump" onward) and submit it as the source of a new, ordinary restore request — no need to browse the catalog by recencybackup-restore.ts, backup-retention.service.ts
force-clear used while pg_restore is genuinely still runningOperator force-clears an in-progress restore rather than waitingRow/maintenance flag clear, but the subprocess keeps running unsupervisedAn operator must independently confirm the process has actually stopped before treating the database as stableworkers/backup-restore.processor.ts
Maintenance file corruptedHand-edited or partially written maintenance.jsonMaintenanceService.isActive() fails closed — refuses customer traffic rather than risk serving a half-restored databaseFix or remove the file; getState logs the read failure loudlymaintenance.service.ts

11. Diagrams Required Per Module

  • Actor capability diagram: Section 3.
  • High-level module flow diagram: Section 5.1's sequence diagram, representative of the shared dump path.
  • Sequence diagram for each major flow: 5.15.7, plus 6.4's activity diagram.
  • State machine diagrams: Section 7, covering backup_run.status, maintenance, and the two-track restore state.
  • Data side-effect diagram: Section 9.
  • Error branch diagram: 6.4's prune flowchart, and the restore sequence diagram in 5.4.

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Manual backupExplicit includeUploads:false is honoured even though the default is also falseSuperadminPOST .../backupsRun recorded with includesUploads:false deliberately, not by coincidence??, not `
Manual backupUnder a remote storage driver, a requested includeUploads:true is silently forced to falseSuperadminSameResponse later shows includesUploads:falseBackupArtifactService.archiveUploads checks StorageManager.hasRemoteDriver()backup-artifact.service.ts
Listsort query param accepted but not applied — only order (on createdAt) is honouredSuperadminGET .../backups?sort=...No error; sort silently ignoredbackup-admin.service.ts
PinUnpinning a backup makes it immediately eligible for the next sweep if it's also past its bucket's retain countSuperadminPATCH .../backups/{id}Backup may be pruned on the next daily sweep with no further actionbackup-retention.service.ts
DownloadFilename is derived from publicId alone, never the operator-editable noteSuperadminGET .../downloadPrevents CRLF header injection via a crafted notebackup-download-admin.controller.ts
DownloadDownload itself fails if its own audit record cannot be writtenSuperadminGET .../download500 BACKUP_DOWNLOAD_AUDIT_FAILED rather than a silent, unaudited downloadAuditLog.create is not fire-and-forget here, unlike everywhere else in the modulebackup-download-admin.service.ts
SettingsEditing dumpTimeoutMinutes takes effect on the next dump, with no restartSuperadminPUT .../settingsRead fresh from settings per dump, not cachedbackup-artifact.service.ts
SettingsChanging scheduleCron/scheduleTimezone reloads the cron registration liveSuperadminPUT .../settingsNext nightly run uses the new schedule without a deployBackupScheduleScheduler.reload()backup-admin.service.ts
SettingsstorageMode of both/remote_only is rejected unless a remote target is configured and remoteReplicationEnabled is also trueSuperadminPUT .../settings400 BACKUP_REMOTE_NOT_CONFIGUREDPrevents an operator believing backups are going off-site when nothing isbackup-settings.service.ts
RestoreBoth acknowledgement checkboxes must be literally true, checked server-side, not just in the formSuperadminPOST .../restore400 BACKUP_RESTORE_CONFIRMATION_MISMATCH on anything elsebackup-restore-preflight.service.ts
RestoreLive-state polling never touches a database tableSuperadmin (polling UI)GET .../liveStays answerable even while every table is locked by pg_restore --cleanReads a file onlybackup-restore-admin.service.ts
RestoreRunner attaches to an already-running process instead of double-spawningWorker (BackupRestoreProcessor)Re-delivered backup.restore job (e.g. after an API restart)Prevents two concurrent pg_restore processes against the same databaseprocess.kill(pid, 0) liveness checkworkers/backup-restore.processor.ts
RestoreConnection fence is released before the state file reports completedRunnerEnd of a successful restoreThe supervisor's own completion query is never blocked by the fence it is about to observerestore-runner.tssame
UploadOperator's own manifest.json is never written to diskSuperadminPOST .../backups/uploadOnly two fields are read from it in memory; a fresh, server-generated manifest is always writtenPrevents reconcileFromDisk from laundering an editable "kind" claim on the next bootbackup-upload-admin.service.ts
UploadUploaded rows are pinned and excluded from GFS retention entirelySuperadminPOST .../backups/uploadAn operator who carried an archive in during an incident cannot have it swept away by the nightly sweepBACKUP_UPLOAD_MAX_RETAINED caps the alternative growth riskbackup-retention.service.ts
UploadContent scan renders schema SQL rather than trusting the TOCSuperadminPOST .../backups/uploadpg_restore --list cannot distinguish an ordinary trigger function from a SECURITY DEFINER one; the rendered SQL can409 BACKUP_UPLOAD_TOC_REJECTEDbackup-archive-content.util.ts
RestoreA pre-restore safety dump is taken inline and awaited, not enqueuedWorker (BackupRestoreProcessor)Every restore, before the runner spawnsRestore aborts (maintenance stays engaged) if the safety dump does not reach completedBackupRunHandler.run() called directly rather than through the outboxworkers/backup-restore.processor.ts
RestoreExisting database connections are drained, not killed outrightRunner (restore-runner.ts)After fencing new connections, before pg_restoreIn-flight customer requests (an order write, a payment record) get up to restoreDrainSeconds to finish on their own before being terminatedrestoreDrainSeconds — admin-editable, injected into the runner's own process environment since it has no DIworkers/restore-runner.ts
RestorerestoreTimeoutMinutes and restoreAbandonSeconds genuinely take effect on the next restore after being savedSuperadminPUT .../settings, then any subsequent POST .../restoreThe pg_restore timeout and the supervisor's abandon window both reflect the saved values, not the deploy-time environment defaultspawnRunner injects the timeout/drain values into the runner's env; supervise takes the abandon window as a parameter from the same settings snapshotworkers/backup-restore.processor.ts
RestorePost-restore row counts are checked against the source backup's own manifest, per tableRunner (restore-runner.ts)After pg_restore succeedsA mismatch is reported as BACKUP_RESTORE_VERIFICATION_FAILED, distinct from a generic failureverifyRowCounts(), exportedworkers/restore-runner.ts
RestoreA manifest table name is validated against a strict pattern before being interpolated into a queryRunner (restore-runner.ts)Row-count verificationAn unexpected identifier is reported as a mismatch, never queried/^[a-z_][a-z0-9_]*$/workers/restore-runner.ts
RestoreThe safety dump's id reaches both the response and the backup_restore rowSuperadmin (polling UI)GET .../live during/after safety_dumpsafetyBackupPublicId is directly readable from the response; backup_restore.safety_backup_run_id records the same link durablyBackupRestoreProcessor.takeSafetyDump writes bothbackup-restore.ts
MaintenanceA permission decorator on a route is honoured as a second signal even if actorType cannot be resolvedAny actorAny request during maintenanceExempted if either signal says "operator"MaintenanceGuard.isOperatorRequestmaintenance.guard.ts
MaintenanceFive literal, hardcoded exempt paths (health probes, login, refresh, and the public maintenance-status route itself) — never a prefixAny actorAny request during maintenanceThe operator can always get back in and disengage, even after every session was just invalidated by a restore; the storefront can always learn the state it exists to reportMAINTENANCE_EXEMPT_PATHSmaintenance.guard.ts
MaintenancePublic status check clears reason when not engaged, regardless of what the shared state still holdsGuest/customerGET /api/system/maintenanceNever shows a stale reason from a previous engagementMaintenanceCustomerService.getStatusmaintenance-customer.service.ts

12.2 Business Process Diagram Pack

See Section 5 for a sequence diagram per flow and Section 6.4 for an activity diagram. A service blueprint for the manual-backup flow:

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Uploads are excluded from a backup by defaultA database-only dump is dramatically cheaper — measured 21.9 MB vs 623 KB for one run on this database's uploads vs. its rows — and doubling steady-state disk on a single VPS for every deployment is a cost most will never needAn operator restoring a database-only backup gets rows that reference images that may no longer exist — broken images, no error, nothing that fails loudlyBACKUP_INCLUDE_UPLOADS env default, includeUploadsByDefault setting, ?? precedence in resolveIncludesUploadsCreateBackupDto.includeUploads is optional; absence means "use the default"BackupArtifactService.archiveUploads also force-disables under a non-local storage driver
The catalog is an index of the filesystem, not the source of truthpg_restore --clean replaces every table including backup_run itself — a catalog living only inside the database it backs up is worthless in the exact disaster it exists forAn operator can trust the backup history to survive a restore or a database loss, because manifest.json is written to disk beside the artifacts, independent of the rowBackupManifestService.writeManifestFile (before completion), reconcileFromDisk (rebuilds missing rows)Not directly visible in any response, but is why reconcileFromDisk runs at bootmanifest.json written before the DB row is ever marked completebackup-manifest.service.spec.ts
storageMode governs where artifacts live; remote retention is out of scopeThe backup host is expected to run its own retention/rotation policy — this feature is a one-way push, never a remote pruneAn operator choosing remote_only must separately manage disk on the remote host; this module will never delete anything thereBackupReplicationService.replicate only ever pushesstorageMode in settings is local/both/remote_onlyLocal artifacts are reclaimed once verified-replicated, unless the run is the overall-newest
Restore needs two independent gatesA permission can be granted by mistake; an environment variable cannot be granted from inside the running applicationAn operator cannot restore the database by permission alone — someone with shell access to the box must also have armed BACKUP_RESTORE_ENABLEDBackup_RESTORE (superadmin-only) + BACKUP_RESTORE_ENABLED (deploy-time)503 BACKUP_RESTORE_DISABLED if either is missingBackupRestorePreflightService.assertFeatureEnabled
The application's DB role must not be a superuser to restorerolconnlimit fencing (the mechanism that keeps live traffic from contending with pg_restore --clean's locks) is not enforced by Postgres against a superuserAn operator who deployed with an overly-privileged DATABASE_URL is blocked from restoring until it is downgraded — a deliberate friction, not a bugassertApplicationRoleIsNotSuperuser409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER
--single-transaction is fixed, never configurableProbed directly: without it, a failed restore mid-way leaves the database in a worse state than before the attempt startedAn operator can never accidentally run a restore without this protectionHardcoded argv in restore-runner.tsN/A (not exposed anywhere)
Maintenance is gated on System_*, not Settings_* or Backup_*An ordinary content administrator must not be able to take the storefront offline, or worse, disengage maintenance in the middle of a restoreOnly superadmin can toggle maintenanceSystem_READ/System_UPDATE, superadmin-onlyMaintenanceAdminController
Three retention floors bind both the scheduled sweep and the manual delete identicallyAn earlier design enforced the floors in only one path, letting an operator delete every backup down to zero through the otherAn operator cannot delete the last restorable backup, the newest in a bucket, or the most recent restore's own safety dump, by any routeBackupRetentionService.assertPrunable, called from both sweep() and the admin remove()409 BACKUP_LAST_REMAINING_CANNOT_DELETE / BACKUP_PINNED_CANNOT_DELETEbackup-retention.service.spec.ts
A restore must not proceed without a completed pre-restore safety dumpProceeding without a way back trades a few minutes of downtime for an unrecoverable mistakeA restore that cannot safely be backed up first is refused rather than attemptedBackupRestoreProcessor.takeSafetyDump — inline, awaited, checked by row status before spawnRunner is ever calledSurfaces as the restore never advancing past safety_dump in GET .../live, then failedBACKUP_RESTORE_FAILED
A restore is verified by row count before being trusted--single-transaction guarantees the database-level outcome is all-or-nothing, not that every table's data matches what was dumpedAn operator is told explicitly, via a distinct error code, when a restore applied but does not match its sourcerestore-runner.ts's verifyRowCounts() at the verifying stage409-shaped outcome surfaced through GET .../live's errorCodeBACKUP_RESTORE_VERIFICATION_FAILED
An uploaded archive must be signed by this deployment's own keyA checksum alone authenticates nothing when the uploader supplies both the file and the hash in the same requestAn operator cannot catalogue an archive from an unrelated or untrusted source, even one that is a structurally valid pg_dumpBackupSignatureService.verify (HMAC-SHA256, timingSafeEqual)409 BACKUP_UPLOAD_SIGNATURE_INVALID, 503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGUREDbackup-signature.service.spec.ts
An uploaded archive's schema SQL is screened before it is trustedpg_restore --list's table of contents records an object's type, not its properties, and cannot distinguish a SECURITY DEFINER function from an ordinary oneAn operator cannot unknowingly catalogue an archive containing a privilege-escalation objectscanArchiveSql, run on pg_restore -f - --schema-only output409 BACKUP_UPLOAD_TOC_REJECTEDbackup-archive-content.util.spec.ts

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Uploads opt-in, off by defaultFaster, cheaper backups by defaultRoughly halves steady-state disk on a single VPSAlways include uploadsRestoring a database-only backup produces broken image references silentlyMitigated only by the operator's own awareness — no warning is surfaced at restore time, since the row-count verification checks database tables against the manifest, not the presence of referenced upload files
Restore accept/execute split into request + async executionThe admin gets an immediate, honest response (202) rather than a held-open connection for a multi-minute operationMatches the reasoning that forced the execution logic out of the request process entirely — pg_restore --clean's table locks would otherwise deadlock the API against its own authFully synchronous restore in the HTTP handlerThe client must poll GET .../live rather than trust the accept responseIncludes an automatic pre-restore safety dump and post-restore row-count verification; the remaining gap is linking the safety dump back to the restore, not the execution or safety net itself
Verified read-back instead of trusting rsync's exit codeAn operator can trust a replicated status actually means the bytes are correct at the far endCompatible with a write-only deploy key (cannot run remote commands to self-verify)ssh ... sha256sum on the remoteCosts a full re-download during verification, doubling transfer time for that stepAccepted — correctness over speed for a database backup
Remote retention explicitly out of scopeSimpler mental model: this feature ships backups off-site, the receiving host manages its own spaceNo need to build and secure a remote-delete code path over the write-only transport this module deliberately usesHave this module also prune remotelyAn unmanaged remote host will fill up over time if the operator does not separately configure retention thereOperator responsibility, stated here rather than assumed
Uploaded archives authenticated by keyed HMAC, not a checksumAn operator can trust that a catalogued upload really came from a deployment holding the right key, not just any file with a matching hashReuses the same BackupSignatureService primitive rather than building a second authentication mechanismTrust an uploader-supplied checksumOperators must provision and protect the key file, and keep the key consistent across deployments that move backups between each otherFails closed (BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED) rather than silently accepting unauthenticated archives
The signing key lives in an app-owned file, not in .env and not in the databaseIt can be generated, rotated and pruned by an operator without a redeploy, and a rotation does not orphan existing archivesNot the database — a dump would then contain the key that signs dumps. Not settings.json — that sits inside BACKUP_DIR, so the key would live in the directory it protectsLeave it inline in .env, where rotating it means editing a file on the box and restartingOne more file to back up separately, and it must never be inside BACKUP_DIR or the upload rootBoot refuses an unsafe path or mode; a corrupt file degrades to unreadable, which REFUSES verification rather than skipping it
Pre-restore safety dump taken inline and awaited, not enqueuedAn operator's mistake is always backed up before it becomes irreversibleThe ordering guarantee only holds if the dump finishes before the runner starts draining connections — enqueuing would let the two raceEnqueue the safety dump onto QueueName.BACKUP like any other dumpThe restore is blocked on the safety dump's own duration before pg_restore even startsIf the safety dump is ever slow enough to threaten the restore's own timeout budget
Maintenance-status route exempted from the guard it reports onThe storefront can always learn a maintenance window is active, even though its own page cache would otherwise hide itReuses the existing 3-layer maintenance state read; no new state to maintainBlock it like every other route during maintenanceDiscloses that the store is offline to any callerJudged not sensitive — every other route already answers 503 during the same window

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
Manual backupDouble-click "Run now"Two requests in quick successionSecond is refused as soon as the first is queuedassertNoRunInProgress checks both queued and running, not only running409 BACKUP_ALREADY_RUNNING, or a DB constraint violation surfaced as a 500 in the rare race window where both checks land before either insert commitsbackup-run.ts
Scheduled backupTick fires while a manual backup is runningNightly cron coincides with an in-progress manual runSame assertNoRunInProgress check applies — the scheduled tick's createRun call is refused and logged, not silently skippedLogged error, no row created; next tick tries againbackup-schedule.scheduler.ts
Retention sweepEvery candidate in a bucket is protected by a floorSmall deployment with few backupsBucket simply does not shrink that dayNo error — the sweep logs 0 pruned for that bucketbackup-retention.service.ts
DownloadBackup's artifact was reclaimed to remote (remote_only) since it completedOperator clicks Download on an older remote_only runRefused — the file is not on this host410 BACKUP_ARTIFACT_MISSINGbackup-download-admin.service.ts
DownloadRecorded checksum no longer matches the file on diskDisk corruption, or an operator tampered with the fileRefused before streaming a single byte409 BACKUP_CHECKSUM_MISMATCHsame
RestoreConcurrent request while one is already acceptedTwo admins both submit a restoreSecond is refused409 BACKUP_RESTORE_ALREADY_ACTIVEbackup-restore-admin.service.ts
RestoreForce-clear called on a restore that is not actually stuckAny restore, including one genuinely mid-pg_restoreSucceeds regardless — no age threshold is checked, and the subprocess keeps running unsupervised{cleared:true}same
RestoreAPI restarts mid-restoreRedeploy while pg_restore is runningThe detached runner survives (setsid, unref()); maxStalledCount: 0 stops BullMQ from re-running the handler and spawning a second pg_restoreRestore completes on its own; the supervisor reattaches to the existing runnerPid on any later job deliveryworkers/backup-restore.processor.ts
RestoreAPI restarts between the safety dump completing and the runner spawningRedeploy in a narrow windowOn reattach, the state file's stage is safety_dump or later, so takeSafetyDump is not called again — only the branch with no already-alive runnerPid calls itNo duplicate safety dumpworkers/backup-restore.processor.ts
RestoreSafety dump races a concurrent retention sweepA sweep tick runs while the safety dump is mid-flightNo conflict — assertPrunable only ever selects completed rows, so a queued/running safety dump cannot be pruned out from under the restore. The safety dump itself is not pinned (unlike an uploaded backup); once completed and linked, it is protected by the most-recent-safety-dump floor (now genuinely functional, since safety_backup_run_id is written) as well as by ordinary count-based retentionNo special handling neededbackup-retention.service.ts
ReplicationThe exact same job delivered twice (BullMQ at-least-once)Retry after a transient worker crashNo-op if already replicatedbackup-replication.service.ts
Public status checkCalled during an admin-toggled maintenance window (not a restore)Operator engages maintenance manually via PUT admin/system/maintenanceSame response shape — the public endpoint cannot distinguish "restore in progress" from "operator maintenance", by design{active:true, reason:"<operator's text>"}maintenance-customer.service.ts
UploadUploading the same archive twiceOperator submits identical bytes againSucceeds — no dedupe by content; a second kind:'uploaded' row is inserted, eventually capped by BACKUP_UPLOAD_MAX_RETAINED201 twice, then 409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHEDbackup-upload-admin.service.ts
UploadArchive passes pg_restore --list but is actually truncatedFormat check only reads the table of contents at the frontThe sha256-vs-manifest comparison catches it, not the format check409 BACKUP_CHECKSUM_MISMATCHsame
isRestorable fieldA completed backup whose local artifact has since been reclaimed (remote_only, verified-replicated)Listing the backupBackupResponseDto.isRestorable reads only status==='completed' — it does not check localArtifactPresentThe admin UI may show a backup as restorable when a restore attempt would actually fail with 410 BACKUP_ARTIFACT_MISSING at preflightbackup-admin.service.ts (toResponseDto)
MaintenanceRestore engages maintenance, then is force-clearedOperator abandons a stuck restoreMaintenance is explicitly disengaged as part of force-clear, not left engagedCustomer traffic resumes immediatelybackup-restore-admin.service.ts

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Take a backupsettings, disk free spacebackup_run, outbox_events, manifest.json, artifact filesbackup.run, maybe backup.replicatestatus, databaseDumpBytes, tableCount, totalRows, isRestorable
Retention pruningbackup_run per bucket, backup_restore (safety-dump floor)backup_run.status/prunedAt/artifactDir, filesystem deletebackup.prune(list view reflects pruned status)
Downloadbackup_run, filesystemMongoDB AuditLogstreamed bytes, not a JSON response
Restoreeverything preflight checks; source manifest.json (row-count verification)backup_restore (including safety_backup_run_id), restore-state.json, maintenance.json, outbox_events, inline backup_run (pre_restore_safety)maintenance Redis mirrorbackup.restore (consumed; takes and links safety dump, spawns restore-runner.ts)RestoreLiveStateDto.stage, safetyBackupPublicId
Replicationbackup_run, filesystembackup_run replication columns, remote hostbackup.replicateremoteConfigured, replicationFailureStreak (via settings)
Uploadsettings.json (retained cap), uploaded manifest/signaturebackup_run, filesystem (dump, fresh manifest + signature)— (synchronous)BackupResponseDto fields, same shape as any other backup
Maintenance status (public)shared maintenance state (cache/Redis/file)noneactive, reason only

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish (each flow's Summary).
  • The doc explains what the backend does that the actor does not see (snapshot capture, manifest-before-row-completion, path recomputation for replication, connection fencing).
  • The doc covers minor flows and branches (12.1, 12.5).
  • The doc includes user (superadmin), worker/system, the detached restore-runner actor, and guest/customer (the one public route).
  • The doc explains business logic, tradeoffs, and rationale (12.3, 12.4).
  • The doc maps every flow to API routes and backend side effects (Section 9, 12.6).
  • The doc includes diagrams appropriate to each flow type (Section 11).
  • The doc covers edge cases and failure recovery, including what the restore-safety net now genuinely does (pre-restore dump, row-count verification) and what still is not automated (linking the dump back to its restore), stated plainly rather than glossed over (Section 10, 12.5).

13. Completion Checklist

  • Every feature and minor action documented is listed: backup (manual + scheduled), retention pruning, download, upload/catalogue (SE-4), restore (request through genuine execution, safety dump, and row-count verification), replication, maintenance as a supporting flow, and the public maintenance-status check.
  • Every actor has allowed and forbidden behavior (Section 3).
  • Every major and minor flow includes steps, branches, and diagrams (Section 5, 12.1, 12.5).
  • Every lifecycle has a transition table and state diagram (Section 7).
  • Every flow links to the API and backend docs.
  • Backend-doc dependencies are called out where they shape behavior — particularly the remaining safety-dump-linkage gap, cross-referenced to the backend doc's 16.8 Risk Register.
  • The upload endpoint (admin/system/backups/upload, SE-4) is fully documented in 5.6 and 6.9.

See Also

On this page

Backup Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Take a backup — manual ("Run now")SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Take a backup — scheduled (nightly)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 Retention pruningSummaryPreconditionsMain Flow — scheduled sweepMain Flow — manual deleteSequence DiagramBranches and Edge Cases5.4 Restore the live databaseSummaryPreconditionsMain FlowWhy --single-transaction is load-bearingSequence DiagramBranches and Edge Cases5.5 Off-host replicationSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Upload and catalogue an operator-supplied archive (SE-4)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Check maintenance status (customer)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7a Getting the uploaded files backVerifying what you downloadedWhat the archive contains, and one thing to know about it5.8 Custody of the artifact-signing keyPruning a retired key, and the two refusalsResolving signatures5.9 Who did this, from where6. Admin Flows6.1 Create6.2 List / Read Detail6.3 Update (pin/unpin)6.4 Soft delete (prune)6.5 Restore6.6 Force-clear6.7 Settings (configure)6.8 Maintenance (supporting flow, not its own feature)6.9 Upload (catalogue)6.10 Key custody7. Lifecycle and State Transitions7.1 backup_run.status7.1a backup_run.signature_state7.2 Maintenance state (supporting)7.3 backup_restore.status and the live-state file's stage — two different state machines9. Data and Side Effects by Flow10. Error and Recovery Flows10a. What the operator sees when a download is refused11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also