docs: resolve backup recovery audit

This commit is contained in:
2026-07-10 12:30:19 +08:00
parent 1094ebd651
commit 599a2f6e66
12 changed files with 997 additions and 9 deletions
@@ -0,0 +1,574 @@
# Backup, migration, and disaster-recovery primary-source research
This note is the primary-source evidence pack for the Wayfinder ticket
“Audit backup, migration, and disaster recovery safety.” External claims use
only PostgreSQL, Prisma, systemd, and rsync first-party documentation. Project
recommendations are explicitly labelled as derived implications rather than
upstream guarantees.
PostgreSQL links below use `docs/current`, which was PostgreSQL 18 when this
research was performed on 2026-07-10. A production implementation must pin the
deployed PostgreSQL major and verify the same pages for that major. The Hub
currently depends on Prisma `^6.19.3`, so Prisma 6-specific command examples
remain relevant even where the live documentation also describes Prisma 7.
## Executive conclusions
1. `pg_dump` can take a consistent **single-database** snapshot while ordinary
reads and writes continue, but it does not include the Hub's workspace tree,
cluster-global roles/tablespaces, or a WAL chain. PostgreSQL now explicitly
says it is generally not the right regular production-backup mechanism
outside simple cases. It is a useful logical/export layer, not proof of
whole-service disaster recovery.
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
[continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
2. Database PITR requires a physical base backup plus a continuous, unbroken
WAL sequence from the backup start through the recovery target. A successful
base-backup command alone does not prove that chain exists.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING),
[making a base backup](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-BASE-BACKUP))
3. `pg_verifybackup` is necessary but not sufficient: PostgreSQL explicitly
requires test restores followed by checks that the restored database works
and contains the expected data.
([`pg_verifybackup`](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
4. Prisma `migrate deploy` applies pending migrations, but it does not detect
schema drift and, on PostgreSQL, Prisma does not wrap a migration in a
transaction by default. A failed migration can therefore leave partial
schema changes requiring explicit operator recovery.
([Prisma production workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production#production-and-testing-environments),
[Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic),
[failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
5. systemd supplies process lifecycle orchestration, not an application-level
consistency barrier. It sends the configured termination signal, waits up
to `TimeoutStopSec=`, then can forcibly kill remaining processes; a
successful `systemctl stop` does not prove that an `ExecStop=` command or
application drain succeeded.
([`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=),
[`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6))
6. The Hub persists one logical service state across two stores: PostgreSQL
records `Project.workspaceDir`, runs, messages, locks, and file-change audit
rows, while workspaces contain project files. A workspace transcript helper
exists but is not wired into the production runner. PostgreSQL's snapshot/
PITR guarantees cover only PostgreSQL. A whole-service recovery point
therefore requires an application-defined database/workspace checkpoint or
a journaled/versioned reconciliation protocol; neither PostgreSQL nor
systemd creates one automatically.
([Hub schema](../../../hub/prisma/schema.prisma),
[workspace transcript helper](../../../hub/src/agent/transcript.ts),
[`pg_dump` scope](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
## Current repository facts that make the boundaries material
- The systemd unit runs `prisma migrate deploy` as `ExecStartPre=` before every
service start and uses `Restart=on-failure`.
([`cph-hub.service`](../../../hub/deploy/cph-hub.service))
- The deploy script updates the live Hub directory using
`rsync -az --delete`, builds in that same directory, restarts the unit, and
accepts a constant liveness endpoint as deployment success.
([`deploy_platform.sh`](../../../hub/deploy/deploy_platform.sh))
- `Project.workspaceDir` is database state, but the referenced directory holds
independently mutable tenant content. A workspace JSONL transcript helper
exists but is not wired into the production runner; file contents still live
in the workspace while the database separately carries run/message/file-change
facts.
([Hub schema](../../../hub/prisma/schema.prisma),
[workspace transcript helper](../../../hub/src/agent/transcript.ts))
These are project facts, not upstream guarantees. The sections below establish
what the upstream tools do and, critically, what they do not do.
## PostgreSQL logical dump and restore
### Snapshot and concurrent-write semantics
- `pg_dump` makes a consistent export even while the database is being used;
it does not block ordinary readers or writers. It dumps only one database.
Cluster-global objects such as roles and tablespaces require `pg_dumpall` or
`pg_dumpall --globals-only`.
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
[whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
- `pg_dumpall` invokes `pg_dump` once per database. Each database is internally
consistent, but the snapshots are not synchronized across databases.
([whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
- A parallel dump uses directory format, opens `njobs + 1` connections, and
uses synchronized snapshots so every worker sees the same data set. The
connection count and additional server load are part of its capacity cost.
([`pg_dump --jobs`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- Concurrent DDL can still interfere. The parallel-dump leader holds
`ACCESS SHARE` locks; if a later exclusive-lock request queues ahead of a
worker, that worker uses `NOWAIT` and aborts the dump rather than deadlocking.
`--lock-wait-timeout` can bound the initial lock wait.
([`pg_dump --jobs` and lock behaviour](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- The ordinary snapshot is suitable for disaster-recovery dump semantics, but
it need not correspond to a serial execution of all transactions that later
commit. `--serializable-deferrable` waits for an anomaly-free snapshot;
PostgreSQL says that option is not beneficial for a dump intended only for
disaster recovery, but it can matter for a continuously updated reporting
copy.
([`--serializable-deferrable`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- The default waits for dump files to be written safely to disk. Production
backups must not use `--no-sync`, because an immediate OS crash can otherwise
leave a corrupt dump.
([`pg_dump --no-sync`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
Derived Hub implications:
- An online database dump may be internally valid while referring to a
`workspaceDir` whose files are from another instant. “`pg_dump` succeeded” is
therefore not a whole-Hub consistency claim.
- Backup scheduling must either serialize against schema migration/DDL or
treat any lock wait, abort, non-zero exit, and stderr warning as a failed
backup. A partially written archive must never be published as restorable.
- If the deployed cluster contains more than the Hub database, a single Hub
dump is not a cluster backup. Even `pg_dumpall` does not create a single
cross-database snapshot.
### Restore error and atomicity semantics
- `pg_restore` defaults to continuing after SQL errors and reports an error
count at the end. `--exit-on-error` is required for fail-fast behaviour.
`--single-transaction` wraps all emitted commands in `BEGIN`/`COMMIT`, makes
the restore all-or-nothing, and implies `--exit-on-error`.
([`pg_restore` error and transaction options](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- Parallel restore uses separate connections for data loading, index creation,
and constraint creation. It supports only custom/directory archives and
cannot be combined with `--single-transaction`; it is not a whole-restore
atomic operation.
([`pg_restore --jobs`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- A single giant restore transaction can be impractical because it locks every
restored object and may exhaust the lock table. PostgreSQL 18 offers
`--transaction-size=N` as a bounded intermediate option; it implies
`--exit-on-error` but is not all-or-nothing.
([`pg_restore --transaction-size`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- Plain SQL restored by `psql` also continues after errors unless
`ON_ERROR_STOP` is enabled; a single transaction is separately required for
all-or-nothing behaviour.
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
- PostgreSQL recommends restoring into a truly empty database based on
`template0`; otherwise local additions in `template1` can cause duplicate
definition errors. Required roles must exist before owner/ACL restoration.
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
- A dump can execute arbitrary code selected by superusers of the source
database. Partial dump/restore does not remove that trust boundary.
([`pg_dump` security warning](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
- A `pg_dump` binary can dump older servers but refuses a server newer than its
own major version. Output is intended to load into newer PostgreSQL versions,
but loading into an older major is not guaranteed.
([`pg_dump` cross-version notes](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-NOTES))
Derived Hub implications:
- Restore should target a new isolated database/cluster. If a non-atomic or
parallel restore reports any error, discard that target and start again;
never promote a partially restored database.
- The runbook must record the restore atomicity strategy and exact PostgreSQL
client/server versions. A green process exit without explicit fail-fast
options is insufficient evidence.
- A restored database must remain disconnected from real Feishu/provider
side-effect endpoints until application-level validation completes.
## Physical backup, WAL, and PITR
### Base backups
- `pg_basebackup` backs up a running PostgreSQL cluster without blocking other
clients. A full backup is an exact copy of the entire cluster and can seed
PITR or a standby; it cannot select one database or object.
([`pg_basebackup` description](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- It uses the replication protocol. The account needs `REPLICATION` or
superuser privilege, `pg_hba.conf` must permit it, and `max_wal_senders`
needs capacity for the backup and, when streaming WAL, another connection.
([`pg_basebackup` requirements](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- Default `-X stream` obtains WAL over a second connection while copying. With
`-X fetch`, WAL is collected only at the end; if required WAL was recycled,
the backup fails and is unusable.
([`pg_basebackup --wal-method`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- The default waits for backup files to be durable. `--no-sync` is explicitly
unsuitable for production because an OS crash can corrupt the backup.
([`pg_basebackup --no-sync`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- A manifest is generated by default, with per-file checksums. A SHA manifest
can detect malicious modification only if the manifest itself is held in a
separate trusted location or otherwise authenticated.
([`pg_basebackup` manifest options](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
Derived minimum backup record:
- source cluster/system identifier and PostgreSQL version;
- backup tool version, start/end time, start/end LSN and WAL range;
- manifest digest and its independently protected location;
- destination object/version, byte count, exit status, and verification state;
- an explicit `VALID`, `INVALID`, or `QUARANTINED` state so a failed leftover
directory cannot enter the retention set merely because files exist.
### WAL archival and recovery
- PITR combines a physical backup with WAL replay. It can stop at a chosen time
and reach a consistent database state. Logical dumps do not contain enough
information for WAL replay.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
- Successful recovery requires a continuous sequence of archived WAL extending
back at least to the base-backup start. PostgreSQL tells operators to set up
and test WAL archiving **before** taking the first base backup.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
- The archive command must return zero if and only if durable archival
succeeded. Non-zero causes retry. Archive implementations should refuse to
overwrite an existing file; a repeated name may report success only when the
existing durable content is identical, and differing content must fail.
([WAL archiving](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- Repeated archive failure accumulates WAL in `pg_wal`; filling that filesystem
causes PostgreSQL to panic and remain offline until space is freed. PostgreSQL
explicitly advises monitoring archival progress.
([WAL archiving failure behaviour](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- WAL is archived after a segment is completed, so low traffic can make the
last committed change wait a long time for archival. `archive_timeout` can
bound that age by forcing segment switches, at the cost of archive growth.
([WAL archiving latency](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- WAL archival does not restore manually edited PostgreSQL configuration files
(`postgresql.conf`, `pg_hba.conf`, `pg_ident.conf`); those need a separate
file backup.
([WAL archiving caveat](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- PostgreSQL's recovery procedure keeps clients out, restores the base backup,
configures `restore_command` and `recovery.signal`, replays WAL, and requires
inspection of database contents before restoring ordinary access.
([PITR recovery procedure](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-PITR-RECOVERY))
- Recovery can target a timestamp, LSN, transaction ID, named restore point, or
the earliest consistent point. Only one target may be set. Timeline history
is required to navigate recovery branches, and a configured target not
reached before WAL exhaustion is a fatal recovery failure.
([recovery target settings](https://www.postgresql.org/docs/current/runtime-config-wal.html#RUNTIME-CONFIG-WAL-RECOVERY-TARGET),
[timelines](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-TIMELINES))
Derived Hub implications:
- The database RPO is the age of the newest **durably archived, continuous**
WAL position, not the age of the newest base backup and not merely the name
in `last_archived_wal`.
- Archive keys need a collision-proof cluster/system-identifier namespace and
create-if-absent semantics; unrelated clusters must never overwrite a shared
WAL name.
- Monitoring must cover archive failures, time since durable success, archive
lag, WAL continuity, `pg_wal` growth, free space, and base-backup age.
- PITR restores PostgreSQL only. It does not rewind workspace files. A database
recovery target has no whole-Hub meaning until paired with a workspace
recovery point and reconciliation rule.
## Verification is a ladder, not a checksum
- `pg_verifybackup` validates the backup manifest and system identifier,
detects missing/extra/changed files, checks file checksums, and parses the
required WAL with `pg_waldump`. WAL verification is currently supported only
for plain-format base backups.
([`pg_verifybackup` checks](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
- PostgreSQL explicitly says these checks cannot cover everything a running
server will encounter and still requires test restores plus checks that the
database works and contains correct data.
([`pg_verifybackup` limitation](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
- `pg_amcheck` can check supported heap/index structures, but currently only
ordinary/TOAST tables, materialized views, sequences, and B-tree indexes are
supported; other relation types are silently skipped. Stronger B-tree checks
can block concurrent modifications.
([`pg_amcheck`](https://www.postgresql.org/docs/current/app-pgamcheck.html#APP-PGAMCHECK))
Derived recovery-evidence ladder:
1. **Artifact verification:** backup command succeeded; manifest and all
required WAL validate; any failure marks the artifact unusable.
2. **Isolated technical restore:** restore to a clean non-production target,
reach the intended LSN/time/timeline, and prove PostgreSQL starts without
exposing it to users.
3. **Database validation:** verify migration history/schema identity, tenant
counts and relationships, critical constraints/sequences, and appropriate
corruption checks.
4. **Workspace validation:** restore the paired snapshot/version, validate file
hashes/ownership/containment, and reconcile database file-change/run state
against actual files.
5. **Application validation:** run read-only tenant-isolation and core workflow
smoke tests with external side effects disabled.
6. **Recovery report:** persist the exact backup, WAL range, workspace point,
release/schema versions, checks, observed RPO/RTO, operator, and failures.
Only a fully passing report may authorize promotion.
## Prisma Migrate production semantics
### What `migrate deploy` proves and does not prove
- In production, `migrate deploy` compares applied migration checksums with the
migration directory, warns when an applied migration was modified, and
applies pending migrations.
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
- It does **not** warn when an already-applied migration is missing, detect
production schema drift, reset the database, generate Prisma Client, or use a
shadow database.
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
- The migration directory is the deployable history and must be committed in
full, including `migration_lock.toml`. Prisma warns against editing or
deleting migrations already applied in production; `migrate deploy` executes
migration SQL rather than deriving a new migration from the Prisma schema.
([Prisma migration histories](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/migration-histories))
- Prisma uses a database advisory lock for production migration commands so
multiple migration commands do not run simultaneously. The documented lock
timeout is ten seconds and not configurable; after timeout, the caller must
retry. The lock can be disabled by environment variable.
([Prisma 6 advisory locking](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#advisory-locking))
Derived Hub implication: `migrate deploy` exit zero proves that this invocation
applied or found no pending migration. It does not prove the live schema equals
the expected migration end state, that the new and previous application builds
are both compatible with it, or that the migration is operationally safe under
production data volume and traffic.
The advisory lock is a migration-command concurrency guard, not a substitute
for the repository's deployment lock or application/schema compatibility. As a
derived safety rule, production preflight should reject
`PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK` unless an explicit reviewed mechanism
provides the same serialization guarantee.
### Transactions and failed migrations
- Prisma's first-party migration guidance states that PostgreSQL migrations
are not wrapped in a transaction by default. Operators may opt in by adding
`BEGIN;`/`COMMIT;` to a migration, but large transactional migrations can hold
locks longer and consume more database resources.
([Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic))
- A failed migration records its error in `_prisma_migrations.logs`; further
migrations cannot deploy until the failed state is explicitly recovered.
Recovery is either (a) revert/repair partial steps, mark the migration rolled
back, and redeploy, or (b) manually complete the exact steps and mark it
applied.
([Prisma failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
- Prisma error codes `P3009` and `P3018` explicitly report that failed
migrations block new migration application until recovery.
([Prisma error reference](https://www.prisma.io/docs/orm/reference/error-reference#p3009))
- `migrate resolve --rolled-back` changes migration-history state so Prisma may
apply the migration again; it does not itself undo SQL already executed.
`--applied` records success without executing the migration SQL.
([failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration),
[`migrate resolve`](https://www.prisma.io/docs/cli/migrate/resolve))
- Prisma can generate a down SQL script, but its documentation limits the
simple rollback workflow to a **failed** up migration. To reverse a successful
migration, the supported history is a new forward migration generated from a
reverted Prisma schema. A schema down script also cannot restore lost data,
application changes, or manually authored SQL automatically.
([generating down migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/generating-down-migrations#considerations-when-generating-down-migrations))
Derived Hub implications:
- Every migration needs a declared atomicity choice. If it is intentionally
non-transactional, the migration must be resumable/idempotent or ship an
exact, tested partial-failure procedure. Blindly rerunning a partially applied
script is unsafe.
- A database backup checkpoint before a destructive or difficult-to-reverse
migration is necessary, but restoring that checkpoint is a whole-database
recovery event—not a substitute for a compatible rollout design.
- Service restart cannot be treated as application rollback after a successful
schema migration. The previous binary may no longer understand the new
schema, and a down migration cannot recreate deleted data.
### Compatibility and rollout ordering
- Prisma documents expand-and-contract as the way to avoid downtime for fields
in active use: add the new representation, deploy code that writes both while
reading the old, migrate and verify data, switch reads, stop old writes, then
remove the old representation.
([Prisma expand-and-contract workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-use-the-expand-and-contract-pattern-to-evolve-the-schema-without-downtime))
- Prisma also warns that generated “renames” may be expressed as add/drop and
lose data unless the SQL is reviewed and customized before application.
([customizing migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-rename-a-field))
Derived deployment contract:
1. Migration SQL is immutable after production application and reviewed for
destructive operations, lock duration, data volume, and transaction choice.
2. It is rehearsed against a recent, anonymized production-scale restore and
its runtime/locks/disk growth are recorded.
3. Each release declares a schema compatibility window. Expand releases remain
compatible with both old and new application versions; contract/destructive
releases occur only after old binaries can no longer run.
4. Migration status/drift is checked before changing the live release. A
failed migration blocks deployment and enters an operator runbook; it must
never be hidden by repeated restart attempts.
5. Rollback means switching to a binary proven compatible with the current
schema or deploying a new forward migration. Restoring a backup is the last
resort and must also restore/reconcile the workspace recovery point.
## systemd stop, restart, and dependency semantics
- When `ExecStartPre=`, `ExecStart=`, or `ExecStartPost=` fails or times out,
service startup does not continue to the normal running process. `ExecStop=`
is skipped because startup never succeeded; `ExecStopPost=` is the hook that
still runs after a failed startup.
([systemd start/stop command semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStartPre=),
[`ExecStopPost=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStopPost=),
[permanent upstream man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L525-L587))
- `Restart=on-failure` covers non-zero exits and failures/timeouts in service
control processes, including `ExecStartPre=`. An explicit systemd stop does
not trigger automatic restart. Restarts are subject to unit start-rate
limiting.
([`Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=),
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L856-L995))
- With no `ExecStop=`, systemd sends `KillSignal=` (default `SIGTERM`).
`TimeoutStopSec=` bounds the wait; after it expires, remaining processes can
be forcibly killed with `SIGKILL`/`FinalKillSignal=`.
([`ExecStop=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
[`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=))
- Default `KillMode=control-group` applies termination to all remaining
processes in the unit cgroup. systemd explicitly discourages `process` or
`none` because children can escape the service lifecycle and remain running
after the service is considered stopped.
([`KillMode=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html#KillMode=))
- `systemctl stop` waits for the job unless `--no-block` is used, but the command
does not fail merely because an `ExecStop=` command failed: the manager still
forcibly terminates the unit.
([`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6),
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L444-L459))
- `systemctl restart` is a stop followed by a start; `ExecStop=` and
`ExecStopPost=` participate. It does not necessarily flush every unit resource
in the same way as separate completed `stop` then `start` operations.
([restart semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
[`systemctl restart`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#restart%20PATTERN%E2%80%A6))
- `After=` is only an ordering relationship; it does not pull in another unit.
Requirement and ordering dependencies are independent. During shutdown the
start order is reversed, so a Hub ordered `After=postgresql.service` stops
before PostgreSQL when both are in the shutdown transaction.
([`Requires=` and ordering](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Requires=),
[`Before=`/`After=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Before=))
- `systemctl freeze` only uses the cgroup freezer to suspend process execution;
its documented contract contains no filesystem flush or stable-image
guarantee. It is not a substitute for an application drain or a filesystem
snapshot barrier.
([permanent `systemctl freeze` man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L653-L675))
Derived assessment of the current unit:
- A Prisma migration failure in the current `ExecStartPre=` prevents the Hub
from starting, then `Restart=on-failure` can retry startup until rate-limited.
That is fail-closed, but it is not controlled migration recovery and can
repeatedly hit a database already left in Prisma's failed state.
- `KillSignal=SIGTERM` plus `TimeoutStopSec=30` is only a deadline. Until the Hub
installs a real termination handler that makes readiness false, stops ingress,
drains/checkpoints active work, closes Feishu and database clients, and reports
success, the unit cannot claim a graceful consistency barrier.
- A backup runbook must issue a controlled drain, verify durable work reached a
reconciled terminal/checkpoint state, stop the Hub, verify the unit and its
cgroup are inactive, and only then snapshot mutable workspace state. A zero
`systemctl stop` exit by itself is insufficient evidence.
- Recovery should not start the normal unit until the restored database and
workspace have been validated. The current unit would run pending migrations
immediately on first start, mutating the restored database before validation
and possibly destroying the ability to inspect the original recovered state.
## Filesystem and rsync boundaries
- PostgreSQL says a plain filesystem copy of a live data directory is unusable:
the database must be shut down, because tools such as `tar` do not create an
atomic snapshot and server buffering remains. A correctly implemented frozen
snapshot can work while PostgreSQL runs, but all volumes containing data,
WAL, and tablespaces must be captured simultaneously; otherwise shut down or
use the continuous-archive base-backup protocol.
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
- For a PostgreSQL rsync backup, the documented safe pattern is a first pass
while running, followed by shutdown and a second `rsync --checksum`; the
second pass is what makes the result consistent.
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
- At the filesystem layer, `fsfreeze` is documented to let in-progress
filesystem operations complete, halt new modifications, and flush dirty data,
metadata, and logs before returning. That is still only a filesystem-volume
guarantee; it does not replace PostgreSQL's backup protocol and does not make
separately frozen volumes simultaneous.
([util-linux `fsfreeze` manual source](https://github.com/util-linux/util-linux/blob/master/sys-utils/fsfreeze.8.adoc))
- rsync's `--delete` defaults to deletion during transfer on modern peers, so
the receiver tree changes incrementally. `--delay-updates` merely holds each
updated file and renames files into place rapidly at the end; its own manual
says this only attempts to make updates “a little more atomic” and points to
a separate parallel-tree/switch approach for stronger atomicity.
([rsync `--delete`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delete),
[rsync `--delay-updates`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delay-updates))
Derived Hub implications:
- The current in-place `rsync --delete` deployment is neither an immutable
release switch nor a backup snapshot. A failed/interrupted transfer can leave
a mixed live tree; service stop/restart cannot reconstruct the deleted prior
artifact.
- The workspace snapshot mechanism remains an explicit design choice. Whichever
storage technology is selected must prove atomicity over all workspace
volumes or use a quiesced/no-writer interval, then publish an immutable
version/checksum manifest. Merely copying a changing directory is not enough.
## Derived minimum whole-Hub recovery contract
The primary sources support the following minimum contract; exact RPO, RTO,
retention, storage vendor, and numeric schedules remain product/operations
decisions and must not be invented by implementation.
### Backup path
1. Maintain verified PostgreSQL base backups and a monitored continuous WAL
archive, or obtain and test an explicitly equivalent managed-PostgreSQL
guarantee. Keep logical dumps as a separate portability/selective-restore
layer, not the sole PITR mechanism.
2. Keep PostgreSQL configuration, deployment/release manifests, migration
history, and workspace snapshots in the backup inventory; WAL alone does not
cover them.
3. Create a whole-Hub checkpoint only after ingress is drained and active
workspace writers are stopped/reconciled, unless the eventual storage layer
supplies a proven atomic snapshot/journal spanning the necessary stores.
4. Persist one signed/immutable checkpoint manifest joining PostgreSQL
backup/WAL position, workspace snapshot/version, application revision,
migration digest, tool versions, checksums, timestamps, and validation state.
5. Treat database/WAL/workspace backups as cross-tenant sensitive data. Apply
least-privilege access, encryption, audit, deletion/retention, and an
independently protected integrity manifest; WAL contains effectively all
database changes and PostgreSQL advises protecting it from unauthorized
readers.
([WAL archive confidentiality](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
### Migration path
1. Serialize deploys and backup checkpoints; run a drift/status gate before
migration, not just `migrate deploy`.
2. Rehearse each migration on a recent production-scale restore and record
runtime, locks, disk/WAL growth, transaction choice, and failure recovery.
3. Require expand/contract compatibility for online changes. A release must
declare which old/new binaries can run against each schema phase.
4. Separate migration orchestration from ordinary service restart so a failed
migration produces one durable incident state and an operator decision—not
an opaque restart loop.
5. Verify a restorable checkpoint before destructive change. Do not describe
backup restore as a normal “rollback”; it is whole-service disaster recovery.
### Restore and promotion path
1. Restore into an isolated environment with real external side effects
disabled.
2. Restore the selected PostgreSQL base backup/WAL target and the checkpoint's
exact workspace version; do not run pending Prisma migrations implicitly.
3. Prove artifact integrity, database startup, recovery target/timeline,
migration/schema identity, tenant invariants, workspace hashes/containment,
and read-only application flows.
4. Record observed recovery point and recovery time. A restore that starts but
misses its target, loses a tenant workspace, has partial migration state, or
fails any invariant is not promotable.
5. Promote explicitly, then enable ingress and external integrations. Preserve
the pre-recovery cluster/WAL and recovery report until the incident is closed.
## Decisions and evidence still required
These values are not fixed by the upstream tools and remain `OPEN` for the SaaS
production contract:
- whole-Hub RPO and RTO, separately for ordinary deletion, database corruption,
host loss, region/provider loss, and migration failure;
- base-backup, logical-dump, workspace-snapshot, WAL, and restore-drill cadence;
- immutable/off-host storage, encryption/key ownership, retention and verified
deletion policy, including org-deletion and legal-retention interactions;
- exact workspace checkpoint/journal technology and database/workspace
reconciliation semantics for in-flight or partially written runs;
- whether restore promotion is whole-platform only or supports a safe
organization/project export-import path (PostgreSQL physical PITR is
cluster-wide);
- migration transaction policy, maximum lock/downtime budget, compatibility
window, and who can authorize contract/destructive phases;
- evidence from at least one production-like full restore and one PITR drill,
including deliberate missing-WAL, corrupted-backup, failed-migration,
interrupted-workspace-write, and restart-during-recovery probes.