PortOS backs up two things together, into a single timestamped snapshot:
- Filesystem data — an rsync mirror of
./data/(with SHA-256 manifest). - PostgreSQL — a
pg_dumplogical dump (portos-db.sql) written alongside the snapshot.
Backup configuration, manual runs, snapshot downloads/deletion, and restore/recovery require operator authority: a verified human or delegated agent session when auth is on, or a genuine local connection when auth is off. A reachable LAN/tailnet caller or scoped federation credential cannot export the raw archive or alter recovery policy. This includes HEAD requests for downloads. Status and snapshot inventory remain readable under the normal API policy. Scheduled backups continue under the operator-configured policy without additional confirmation.
The backup service closes a shared cross-process publication boundary before copying
data/, drains music-track take publications already in progress, and keeps
new take publications waiting through the SQL dump and manifest write. Browser
code takes, SuperCollider takes, chiptune renders, and painted-waveform renders
stage encoding outside that boundary, then publish their final audio bytes and
track row together inside it. Manual and scheduled backups use the same cut.
If an admitted take does not drain within two minutes, or the cut cannot be
released, the snapshot is marked failed (never published or used for retention
pruning). A normal drain timeout releases its own cut; an unreadable or interrupted
owner remains blocked until its data is reconciled. The protocol and deliberate
recovery procedure are documented in Shared publication admission.
Media-job completion (#9981). A render's completion holds the same admission
from staging its terminal queue row (media-jobs.json) through the completed
event fan-out; it is never held while rendering, waiting in the queue, calling a
provider, or for failure and cancellation outcomes. Each attach hook that names
the render's file in a row takes its own admission synchronously inside that
fan-out, so a cut already waiting to drain still waits for it. A completion that
arrives during the cut leaves the job running in the queue snapshot (the
finished file is an unreferenced extra, never a dangling reference) and
publishes once the cut releases; the lane stays occupied meanwhile, but the
renderer is not stopped and cancellation is unchanged. A terminal write that
fails inside the admission is retried inside it and releases it when it gives
up. Only a queue snapshot written inside an admission lease commits a staged
completion: once storage recovers, an unrelated queue write (progress, a new
job, the shutdown flush) keeps that job running on disk and hands it to a
recovery commit that takes its own lease, waiting out any open cut, so the
terminal row and its attach hooks still publish together.
Database maintenance shares the same boundary and never terminates a render or stops PM2 to obtain it:
- A database restore (execution only; preview takes no cut) acquires the cut before
it publishes its recovery journal. Admitted file-plus-row publications drain
completely first, so the replay never lands under a half-published pair. If a
backup owns the cut, or a publication does not drain within two minutes, the
restore returns
backup_snapshot_busywithout changing anything. - Accepting a backend cutover takes the cut around publishing the maintenance
fence (it waits up to 30 seconds to drain, and refuses with
DATABASE_PREFLIGHT_BUSYwhile a backup owns it). The fence is published before any new publication can be admitted, and every admitted one has already finished. Once it exists, a backup refuses to start or to take its cut (DATABASE_MAINTENANCE) — one that already reserved a snapshot marks it failed and preserves older recovery points — and ordinary database writes are refused, so no row can reference a file the fence stranded. Cancelling the operation reopens everything; nothing stays held. - Every release is owner-scoped: a failed or cancelled operation releases only the cut it acquired and leaves the maintenance journal and restore-recovery incomplete guards exactly as they were. Durable ownership coordinates the server and CoS runner sharing this install. Both processes must run the updated protocol; the detached cutover worker still runs only after the server stops.
Admission inventory (withBackupAssetPublication):
| Owner | Status |
|---|---|
Music Designer take publication (musicTakePublication.js) |
Covered (#9980) |
Media-job completion: queue terminal row + completed fan-out (mediaJobQueue/index.js) |
Covered |
Attach hooks on completed via mediaJobImageHook.js (writers-room, catalog, music-video scene image/video/cast-sets, CD scene image/music bed, FableLoom scene image/video, sprite references, deck cards, music studio) |
Covered |
Pipeline filename hooks (filenameHookFactory.js comic pages and storyboards, seasonCoverFilenameHook.js) |
Covered |
Recovery commit of a completion whose terminal write failed (mediaJobQueue/index.js) |
Covered: only an admitted snapshot commits it (#9982) |
Universe Builder completion listeners: collection filing, canon entry-ref append, and sidecar enrichment (universeBuilderCollectionHook.js); character reference sheet copy and pointer stamp (universeCharacterSheet.js) |
Covered (#9982) |
Creative Director evaluation frames: render-completion sampling (creativeDirector/sceneRunner.js) and the resume pass that re-samples missing frames in place (creativeDirector/completionHook.js) |
Covered from the first ${jobId}-fN.jpg write through the scene row that names them (#9982) |
Creative Director render settlement (scene status and auto-accept, plan-step settle, seed-frame wait) and Music Video production step settlement (musicVideo/productionService.js) |
Reference-only: these write no bytes, and the render they name was on disk before its admitted completion published |
Sprite animation completion: clip copy, frame packaging and run record (sprites/localAnimationJobHook.js) |
Covered from clip staging through the run record that names the packaged frames (#9982) |
Sprite reference lock and unlock, walk set finalization, unlock, reopen and anchor/turnaround revision, source-pipeline import, and the grok-TUI attach lanes for walk and named tracks (sprites/reference.js, sprites/walk.js, sprites/importer.js, sprites/animationTrackWorkflow.js) |
Covered (#9982): each holds one lease from its first byte through the sprite row it pairs with (or, for the attach lanes, the run record naming the packaged frames) |
Sprite generation starts, reference uploads, candidate and run records, selections, loop trims, atlas compile, publication history, asset deletion and the publish-binding row (sprites/walkTrims.js, atlas.js, assets.js, publish.js, and the non-lock paths of reference.js, walk.js, animationTrackWorkflow.js) |
Covered (#9982): shared animation/reference queues acquire admission before serialization; file-primary records and their bytes must drain together, regardless of the metadata-only sprite DB row |
Direct gallery upload, image prompt/visibility sidecar replacement, and image deletion (imageGen/local.js) |
Covered as one file/sidecar/index workflow (#9982) |
Gallery image deletion's universe canon purge (galleryImageDeletion.js) and character reference sheet deletion (universeCharacterSheet.js) |
Covered from file removal through the universe pointer purge (#9982) |
Video-history deletion, including downloaded-video deletion (videoGen/historyOps.js) |
Covered through file/history/index removal (#9982) |
Gallery video upload, download finalization and poster edits (videoUpload.js, videoDownload.js, videoGen/poster.js) |
Covered (#9982): uploads lease byte installation through history and rollback; downloads lease poster creation through history and rollback after yt-dlp finishes; poster edits lease creation, history and cleanup |
LoRA dataset uploads, gallery imports, reference-sheet crops, generated completion/recovery copies, image/dataset deletion, and queued record edits (loraDatasets.js, loraDatasetGenerate.js) |
Covered as complete file/record workflows (#9982) |
Voice Studio audition and character assignment (voice/studio.js) |
Covered from source-file write/copy through profile-row commit and failed-write cleanup (#9982) |
Music Video development artifact import/generated save and vocal-stem attachment (musicVideo/devArtifactService.js, musicVideo/vocalStem.js) |
Covered from final file copy/write through project-record commit and failed-write cleanup (#9982) |
Music-library upload copy and deletion (pipeline/musicLibrary.js) |
Covered (#9982): the copy and the unlink each hold one lease; the delete route intentionally leaves existing issue/project references to the removed file |
Voice benchmark audio and row (voice/profileBenchmarks.js), cloned-candidate recording and row (voice/profiles.js), and the fine-tune job record that names each sealed checkpoint (voice/fineTuning.js) |
Covered (#9982); promoting a checkpoint writes only a row naming bytes the job record already named |
Pipeline audio stage rows: generated music, cue render and voice-over line render (routes/pipeline/audio.js) |
Covered (#9982): the row that first names the WAV takes the lease; the sidecar or synthesizer writes the WAV before it |
Music-library upload attach: Music Designer upload, pipeline music upload, YouTube import and Suno import (routes/tracks.js, routes/pipeline/audio.js, trackYoutubeImport.js, trackSunoImport.js) |
Reference-only: the admitted library import copies the file first and the track or issue row commits after it |
LoRA training and deployed LoRAs: trained-adapter registration, checkpoint promotion over the deployed adapter, the promoted-checkpoint preview copy, the progress row naming trainer-written samples and checkpoints, run deletion, and LoRA deletion from the LoRA manager or Media Models (loraTraining/index.js, loras.js) |
Covered (#9982): registration and promotion hold one lease from the adapter write through the run row and dataset flag; Civitai and Hugging Face installs admit the sidecar that first names the already-linked weights |
Music Video final and excerpt renders, publishing-kit builds and composition document versions (musicVideo/render.js, musicVideo/excerptRender.js, musicVideo/publishKit.js, musicVideo/compositionDocument.js) |
Covered (#9982): the encoder or import writes the files in place; the history entry and project row that first name them commit under one lease |
Music Video excerpt deletion (musicVideo/excerptService.js) |
Reference-only: the project row drops the excerpt before its unreferenced video and contact sheet are unlinked |
Music Video performance repair, MIDI transcription and the autonomous Suno song (musicVideo/performanceRepair.js, audioMidiTranscription.js, musicVideo/autonomousService.js) |
Covered (#9982): the boundary frame or .mid is written first; the row that first names it (repair revision, project MIDI pointer, track render) commits under one lease, and a declined MIDI result is unlinked inside it |
Writers Room draft bodies: new work, draft save and version snapshot (writersRoom/local.js) |
Covered (#9982): each holds one lease from the .md write through the manifest row that names it; a draft save replaces its body in place |
Writers Room tombstone prune (writersRoom/sync.js) and polish snapshots (writersRoom/polish.js) |
Reference-only: the prune drops the rows before it removes their directories, and polish snapshots are JSON files no row names (revert writes the draft through the admitted save) |
Image-to-3D mesh completion and AR export (imageTo3d/models.js) |
Covered (#9982): the runner writes model.glb outside admission and only the row that marks it ready takes the lease; the AR export's file write and the row stamping it are one lease |
Image-to-3D record deletion (imageTo3d/models.js) |
Reference-only: the row is soft-deleted before the render directory is removed |
Rigging and animation retarget (rigging/autoSkin.js, rigging/retarget.js) |
Covered (#9982): the pair is published into its own directory and verified outside admission; the row that first names it takes the lease |
Catalog ingredient media and voice-memo scraps, imported round reference audio, Persistent Mind screenshots and songbook attachments (catalogMedia.js, catalogIngestSources.js, roundReferenceAudioImport.js, persistentMindAttachments.js, routes/brainSongbook.js) |
Covered (#9982): the row that first names a landed file takes the lease; the file-backed Persistent Mind and songbook records hold it through their deletions too |
Mood board re-hosting, Pinterest and X imports, frame extraction and collages (moodBoard/*.js) |
Covered (#9982): downloads and renders run outside admission; the board row that first names them, and each in-place rewrite of a URL-keyed download, take the lease |
Code Animation generated HTML, package import and repair revisions, production stage-run artifacts, Blender film publication and the soundtrack mux install (codeAnimation/index.js, projects.js, stages.js, blenderRender.js, sound.js) |
Covered (#9982): write-once revision trees and run artifacts land first, and the revision, run or history row that first names them commits under the lease; the mux's in-place install over an already-named render takes it too |
Code Animation export staging and output acceptance (codeAnimation/export.js, codeAnimation/acceptance.js) |
Reference-only: staging directories and worker workspaces are scratch no row names, and acceptance names a render that was already durable |
Peer asset, draft-body and bible pulls, share bucket asset import and the peer library sweep (sharing/peerSyncAssets.js, importer.js, peerMediaLibrarySync.js) |
Covered (#9982): downloads run outside admission; the write that lands the bytes with its sidecar, poster and media_assets row, the draft or bible replacement, the bucket's asset copy and the sweep's index rebuild each take the lease |
Peer CoS archive imports (sharing/peerCosSync.js) |
Covered (#9982): verified downloads stage outside admission; final files, index reconciliation and present-but-unindexed recovery take one lease |
ChatGPT archive import and import-memory deletion (chatgptImport.js, brain.js; the ZIP's asset extraction in chatgptZipImport.js) |
Covered (#9982): assets are extracted outside admission and named only by the memory row; each conversation's archived transcript and that row commit under one lease, and deleting an import memory drops the record and unlinks its transcript and unreferenced assets under one lease |
YouTube ingest (youtubeIngest.js) |
Covered (#9982): downloads run outside admission; the index record that first names a transcript or audio file takes the lease, and forgetting an ingest drops the record and unlinks its files under one lease |
Digital twin documents, enrichment answers/lists, peer document sync and genome upload/delete (digital-twin-documents.js, digital-twin-enrichment.js, digital-twin-sync.js, genome.js) |
Covered (#9982): each document file or raw genome file and the meta record naming it is one lease, including deletion; enrichment provider work stays outside, while peer sync holds the lease from document copying through metadata save and tombstone reaping |
Derived media index (mediaAssetIndex/) |
Rebuilt: media_assets rows are excluded from snapshot dumps; database restore rebuilds atomically from disk before reopening admission, and relevant file restores refresh the mirror |
Explicit file purge (dataManager.js, routes/uploads.js, routes/attachments.js) |
Covered: single and bulk deletions hold one lease. These operator-directed removals may intentionally leave external references; admission prevents a snapshot from interleaving with the removal, not from preserving that already-deleted state |
Image generation completion, upscale/clean tails, variants and sketch pairs (imageGen/*.js, mediaSketches.js) |
Covered (#9982): provider output publication through sidecar and cleanup takes one lease; remote replacement and variant/sketch rewrites restore previous files on failure |
| Pipeline audio mux (music, voice, generated cues, silent strip), including Creative Director stitch/final assembly | Covered (#9982): encoding runs outside admission; replacement of the already-recorded video and rollback hold the lease. CD final/rough-cut rows only reference the existing history entry |
Music Video Making-of export (makingOf.js, makingOfVisuals.js) |
Reference-only: reads existing assets and transforms buffers for the ZIP response; no durable file or row writes |
Time Capsule snapshots (timeCapsule.js) |
Covered (#9982): snapshot creation and deletion lease the file and index entry together, acquiring before the shared index write tail |
CoS raw recording compression and purge (cosAgentStorage.js) |
Covered (#9982): verified gzip publication, storage manifest and plain-file removal share a lease; purge intent and unlinks also share a lease because the manifest is file-primary |
CoS agent archive/index publication (cosAgentLifecycle.js, cosAgentArchive.js, cosAgentIndex.js) |
Covered (#9982): completion, zombie/stale archival, deletion and legacy layout migration lease directory changes through state and index publication; state-locked workflows acquire admission first |
Game compiled manifests (games/compile.js) |
Covered (#9982): versioned manifest and game compiled/history pointers hold one lease before the per-game queue |
Toolkit run creation, CLI/API terminal recording and deletion (aiToolkit/runner.js, internal/runFinalizer.js) |
Covered through the host-injected withAssetPublication boundary. Standalone toolkit defaults to direct execution. Terminal hooks run after release; output failures retain truthful persisted byte counts. The injected boundary coordinates the server and separate runner processes |
Beeper attachment mirror (beeperAttachments.js) |
Covered (#9982): network transfer stays outside admission; content-addressed installation/dedupe and its local_path row commit share admission and a path queue with eviction/orphan cleanup. This also protects snapshots that opt into the normally excluded attachment bytes |
Runtime recording completion tails (runtimeFilePublication.js, server runner/TUI finalizers, CoS runner completion, loop iterations, TUI spooler) |
Covered (#9982): output and completion metadata are admitted together with rollback on write failure; CoS completion retains admission through runner ownership removal. Each parsed/raw spool flush and CLI transcript/state batch is a short lease, including durable truncation/failure flags. Spawn prompt files/run creation/registration publish after forge preparation; old run pruning and feature-agent row/directory deletion are admitted. Model execution stays outside. Uses the shared process admission protocol in both server and CoS runner |
Agent usage run creation and completion (agentRunTracking.js) |
Covered: prompt/output creation precedes metadata inside one lease; completion holds output replacement through terminal metadata, with usage and event callbacks after release |
Snapshot consistency claim (backupAssetOwners.js, see below) |
Covered (#9982) |
Database restore execution and backend-cutover acceptance (backup.js, databasePreflight.js) |
Covered (#9983) |
Direct gallery uploads encode before admission, then publish the final image, sidecar, and derived index row under one lease. Prompt/visibility edits and image/video deletions take admission before their first read or removal and hold it through the index update. A snapshot requested halfway through one of these workflows drains it; a mutation arriving during a snapshot waits until the file copy, SQL dump, and manifest are done. These leases do not make the two stores transactional: existing best-effort index failures can still leave stale derived rows for reconciliation, and a delete can leave other records that referenced the asset. The admission timeout/failure path still refuses the snapshot and preserves older recovery points.
LoRA datasets keep their metadata beside their images rather than in PostgreSQL.
Their admitted workflows prevent rsync from capturing a half-applied image/record
mutation too: upload/import normalization, reference-sheet cropping, generated
image copies and recovery replacements, and image/dataset removal each hold one
lease through their final metadata or file operation. Record edits acquire
admission before joining the dataset write queue. Completion listeners acquire
their own lease synchronously during queue fan-out; rendering, queue waits,
captioning, and the vision crop proposal stay outside admission. Local image
normalization/cropping and copying are included in the lease. Pending rendering
entries can still name files that do not exist yet, and admission does not repair
pre-existing missing files or make failed writes transactional.
Voice Studio holds admission after inference has produced its WAV and before creating or copying final source assets, until the corresponding profile row commits or failed-write cleanup finishes. Music Video development artifacts validate their media policy outside admission, then hold one lease from the versioned file write through project-record mutation and cleanup. Vocal stems probe the master and upload outside admission, then hold one lease from library copy through the project update. A failed row write can still leave an unreferenced library stem; it cannot make a completed snapshot point at absent bytes.
Universe Builder render listeners take their lease synchronously inside the
completion fan-out, so a cut that is already draining waits for a character
sheet's copy and pointer stamp, or a render's collection filing, entry-ref
append and sidecar enrichment. The gallery delete route holds one lease from
removing the image through dropping every universe canon imageRefs entry that
named it, and reference-sheet deletion holds one from the pointer read through
the pointer purge. Sidecar enrichment shares the gallery's per-image edit queue,
so it cannot drop a concurrent prompt or visibility edit. Records outside
universe canon that named a deleted image (media-collection items, for example)
keep their references; only universe canon references are purged with it.
Creative Director frame sampling holds its lease while ffmpeg decodes the clip, a few seconds for a typical scene; the evaluator dispatch that follows stays outside it.
Sprite animation completion files the finished clip under one lease: staging the MP4, decoding and packaging its frames, and the run record that names them. It takes the lease before the per-record write tail, so a filing queued behind a long Reprocess holds its lease while it waits and can stretch a cut's drain by that wait. Voice benchmarks synthesize outside admission, then write every WAV and the benchmark row under one lease. A fine-tune job's checkpoints are written by the training process, which cannot be admitted, so the job record that first names each sealed checkpoint takes the lease instead. The music library's upload copy and its deletion each hold a lease; deletion leaves the issues and projects that named the track pointing at the removed file, as before. The pipeline audio routes (music generation, cue render, voice-over line render) hold the lease only around the row commit: the sidecar or synthesizer has already written the WAV, possibly while a cut was running, and a row that waits out the cut can only name audio the snapshot copied, or nothing. Music library uploads need no lease of their own at the row: the admitted import copies the file first.
LoRA training registers its adapter, sidecar, run row and dataset flag under one lease, and checkpoint promotion does the same while it overwrites the deployed adapter in place; the completion event fires after the lease so the media queue's own admitted commit never borrows it. The trainer writes samples and checkpoints itself, so only the progress row that first names them takes the lease, and a progress-only update never waits out a cut. Run deletion holds one lease from the artifact-directory removal through the LoRA unlink, the dataset reset and the row delete. A LoRA deleted from the manager leaves runs and other records that named it pointing at the removed file, so that unlink holds the lease too. A downloaded LoRA is linked under its final name before its sidecar is written; the sidecar write takes the lease, so a snapshot can hold weights without their sidecar (they list with fallback metadata) but never a sidecar without its weights.
A Music Video render holds no lease while it encodes. Once the MP4 is on disk,
the final render's poster, its video-history entry and the project row naming
that entry commit under one lease; an excerpt's settling row (naming its MP4
and contact sheet) does the same. A publishing-kit build commits the kit row
naming its encodes, thumbnails and captions under a lease, then removes the
previous kit's files no project still names. A composition document version is
renamed into place before the row that selects it commits under a lease, and
pruning removes only version folders no row names. The in-flight
renderPartialFilename / excerpt partialFilename marks still name a file the
encoder is writing, as before; boot recovery clears them.
Writers Room draft saves hold one lease from the in-place .md replacement
through the manifest row, so a cut never copies old prose beside a row that
describes the new text; creating a work and snapshotting a version do the same
for the new body file. A mesh the image-to-3D runner writes in place stays
unreferenced until the row that marks it ready commits, and that commit takes the
lease; a cut taken mid-render dumps the generating row, which restore recovers
as failed. Rig and retarget pairs are never replaced: the verified pair sits in
its own directory and only the row naming it takes the lease. Deleted works and
models keep their directories until tombstone GC or the render settles, after
their rows stopped naming them.
Catalog uploads and voice memos, a voice-memo scrap and an imported round
reference audio land their file first; the row that first names it commits
under the lease, so transcription, extraction and the yt-dlp download stay
outside. Persistent Mind screenshot records and songbook attachment lists live
in data/ files, which one rsync pass copies at a different moment than the
bytes, so their deletions hold the lease from the unlink through the record
write, and a songbook upload holds it from the byte write. The Persistent
Mind's expired-upload sweep, which runs before each message and upload, skips
its pass while a cut is pending instead of delaying them. Mood board downloads,
frame extraction and collage rendering run outside admission; the board row that
first names the result commits under the lease. Re-hosted, Pinterest and X
downloads are keyed by their source URL and rewrite the file in place on a
repeat, so that write takes the lease too. Removing a board item or a board
removes no bytes. A row that fails after its file landed leaves an unreferenced
file, as before.
Gallery video uploads hold admission from the first byte installation through their thumbnail and history entry; failed history writes remove both outputs before releasing admission. The yt-dlp producer downloads fresh, unreferenced files outside admission, then the poster and history commit take one lease, including rollback. Failed or canceled producers remove their unreferenced fragments through the existing download core. Poster edits take admission before the shared history write tail and retain it through poster creation, history replacement, old-poster cleanup and failed-commit cleanup. Every new poster uses a fresh basename; temporary sharing copies stay outside this durable workflow. Upload and download completion notifications run after the durable commit, so a throwing listener cannot remove files that committed history already names. These leases do not repair preexisting dangling references. The derived media index can lag live edits; it is excluded from dumps and rebuilt from authoritative sidecars and history during restore as described below.
Local generated videos (including each batch member) and Grok, fal and Reactor videos share an admitted finalizer. Fresh producer output is unreferenced until that finalizer takes admission before faststart and poster creation, then commits serialized history or removes the owned video, possible partial poster and faststart staging file before releasing the lease. A missing thumbnail retains the existing thumbnail-less behavior. Terminal status and notifications follow the durable commit. Caller publication latches keep committed outputs and earlier batch members out of later failure or cancellation cleanup. Federated video transfers verify a staging file outside admission, then lease replacement, poster generation and history together. A failed replacement restores the prior clip and poster before releasing admission; replay replaces the existing row. Derived stitch, inline and queued upscale, timeline and HTML-composition renders produce fresh output outside admission, then lease poster and history publication through rollback. HTML compositions commit all formats together.
The derived media index is excluded from snapshot dumps: its asynchronous refresh may lag authoritative files, so preserving its rows would preserve stale file references. Both legacy and new database restores rebuild it atomically from local sidecars and video history before reopening admission. Unreadable sources or SQL failure keep recovery fenced for a same-operation retry, without replaying the dump. Full and media-selective file restores rebuild it too; a failed rebuild is reported as a reconciliation failure. This does not make unadmitted authoritative media workflows consistent.
Code Animation writes its HTML, revision trees and run artifacts before the row
that first names them. A generated animation's HTML and the job row marking it
completed hold one lease. A package import or repair stages its revision into a
fresh write-once directory outside admission, then commits the revision row
under the lease; the import's staging run row names that directory before
its bytes land, but it is never a revision and a restart marks it interrupted.
Every production run-row write takes the lease, because the row is what first
names the stage's frames, soundtrack WAV and Blender bake. A Blender film is
copied under a fresh name and muxed before its history entry commits under the
lease. A browser render's history entry is written by the HTML-composition job
under admission, so the soundtrack mux holds the lease
across its in-place install over that file, but not across the encode.
A sprite's row holds metadata and workflow state (status, the frozen chroma key,
the publish binding), never a path, and its bytes live under data/sprites/<id>/.
The workflows whose row mirrors a file state therefore hold one lease: reference
lock and unlock (manifest and row), walk set finalization (the set, then the
row that says walk-complete), and unlock, reopen and revision (remove the set,
then downgrade the row). A copy that predates the set paired with a row dumped
after it would advertise a finished walk with nothing behind it, which the write
order was chosen to prevent against a crash but cannot against a copy-then-dump
snapshot. A source-pipeline import holds a lease per subject, a character or a
props family, from its first copied byte through the row marking it imported, so a
multi-subject import lets a cut in between subjects. The grok-TUI lanes run the
terminal session outside admission and take the lease for the attach that packages
its frames and files the run, as the local completion hook does. The leases are
taken before the per-record write tail, so a workflow queued behind a long
reprocess holds its lease while it waits and can stretch a cut's drain by that
wait. Generation starts, uploads, run records, selections, loop trims, atlas
compiles, asset deletion and publication also take admission before the shared
animation/reference write tails. Their file-primary manifests and runtime
pointers need the same protection even when the PostgreSQL sprite row contains
no path: writing a record last alone cannot protect a multi-file rsync pass.
Snapshot consistency claim. server/lib/backupAssetOwners.js inventories
each durable owner as admitted, reference-only or outstanding, and its
test fails when an admitted entry stops taking the lease or a module that
takes the lease is missing from the inventory. Every snapshot records the claim
derived from it as assetConsistency in its manifest.json, the backup status
(GET /api/backup/status) and the run result. scope: "global" is reported
only when no owner is outstanding; until then the scope is admitted-owners
and outstanding lists the owner ids still outside admission. An unrecognized
status counts as outstanding, never as covered. Snapshots written before this
field existed carry no claim and must be treated as partial.
The completed inventory covers the identified managed runtime workflows in this
repository. New byte-plus-record workflows must be classified before they can
participate in the guarantee; an outstanding or unknown status downgrades the
snapshot claim. The global scope requires both server and CoS runner to use the
shared protocol. It excludes arbitrary external scripts/tools and does not repair
preexisting missing assets, deliberate operator deletion, or corruption left by a
failed migration. Managed startup migrations finish before backup routes and the
scheduler become available; this ordering prevents concurrent capture, not a
claim that every migration necessarily succeeded.
No authoritative asset domain is excluded merely to satisfy readiness. Existing explicit backup exclusions still apply; the derived media index is reconstructed from authoritative files instead of preserving cache rows.
When wrapping one of them, remember the order a cut works in: it copies files, then dumps rows. A row that names new bytes therefore has to commit under the lease unless the lease already covered the byte write. A deletion that removes a PostgreSQL row first and the bytes second is safe either way, because the dump that follows the copy sees no row. A file-primary record and its bytes require admission even for record-first deletion: rsync copies them independently. A deletion that unlinks first, or one that leaves rows naming the removed file, must hold the lease around the unlink.
This is part of the cross-store consistency work.
While assetConsistency.scope is admitted-owners, status: ok reports that
the file copy, manifest, and database dump completed; it does not assert that
every database asset reference resolves to the captured filesystem bytes. A
restore operator should verify the outstanding owners' assets before treating
a snapshot as a complete recovery point.
Now that PostgreSQL is a required dependency (it owns the creative catalog, memory, and a growing set of app-native records — see Storage Classification Contract), the database dump is part of required system state, not an optional extra. A snapshot that captured data/ but failed to capture the DB is incomplete, and PortOS surfaces that explicitly.
The dump includes the machine-local cos_pending_agent_feedback reference index, the cos_tasks CoS task queues and their completed-task history (the Markdown task files were retired into it and kept as *.imported), the cos_agents CoS agent working set (moved out of data/cos/state.json, whose pre-migration copy is kept as state.json.imported), and review_queue_triage presentation markers. The queue's source records remain in their owning stores; restoring the dump therefore preserves snooze/dismissal decisions without exporting them through federation or duplicating source payloads.
Implementation: server/services/backup.js (snapshot/dump/restore), server/services/backupScheduler.js (cron), server/routes/backup.js (API), and server/routes/database.js (DB export/sync).
Snapshot dump and replay use the endpoint and credentials captured by the active
pool at server startup. Changing PGHOST, PGPORT, PGDATABASE, PGUSER, or
PGPASSWORD requires restarting PortOS. Inherited libpq overrides (including
PGHOSTADDR, service files and PGOPTIONS) are removed from these subprocesses.
TLS is captured at startup too: disabled TLS stays disabled, verified TLS uses
verify-full with a temporary PEM file containing Node’s active trust roots, and no-verify uses require. Inline TLS
certificate settings are refused rather than silently weakened. Executable
resolution (PATH and PORTOS_PGDUMP) is preserved.
Settings (Database tab) offers a coordinated offline cutover between Docker and
native PostgreSQL. It stops/drains every PortOS writer (including the CoS
runner), transfers the data, commits the new backend, restarts, and verifies
the restarted server's target connection before reporting success — the same
lifecycle POST /api/database/maintenance/cutover runs for automation (the
Settings tab submits that same request; it uses the ordinary instance
authentication gate). scripts/database-maintenance.mjs is not a complete
migration path: its begin command only admits a fence and starts nothing (see
Database maintenance admission below).
scripts/db.sh migrate, use-native, and use-docker still refuse directly;
native setup provisions without selecting a backend. Do not substitute Sync
followed by Switch — that copies data without stopping writers or verifying
the restart, and can strand writes accepted after the snapshot.
Expect brief downtime while PortOS restarts. The Database tab shows the operation's stage (preparing, exporting/importing, restarting and verifying), never reports success from an accepted request, a reconnect, or a saved-mode change alone, and reconciles the outcome from the durable maintenance journal on socket reconnect and page reload — because the server it is watching is the one restarting. If the cutover worker exits before finishing, the tab shows an interrupted state with a Resume control that continues the same recorded operation; there is no rollback, force, or skip.
If a previous migration left records missing, preserve both backend databases and the migration dump. The source may hold accepted writes absent from the target; overwriting or deleting it can destroy that recovery copy. Compare and recover the missing records before choosing an authoritative backend. See storage mode guidance.
Standalone exports and imports can bind all connection fields explicitly:
scripts/db.sh export --endpoint localhost 5432 example_user example_db recovery-copy
scripts/db.sh import --endpoint localhost 5561 example_user example_db data/db-dumps/portos-recovery-copy.sqlThese commands require host pg_dump / psql and never fall back to a Docker
container or select a backend from saved mode. Use a pg_dump version compatible
with the source PostgreSQL server. The four endpoint arguments are host, port,
user, and database name; connection strings are not accepted as database names.
Supply credentials through PGPASSWORD, never as command arguments.
A failed export does not replace an existing dump.
Import retains the dump and uses one transaction with ON_ERROR_STOP.
An explicit endpoint only selects where a standalone transfer runs. Import
replaces database objects present in the dump: keep recovery copies and stop
the target's writers before restoring. These commands do not stop PortOS,
change saved mode, verify a restarted pool, or authorize a backend cutover —
use the coordinated cutover above (Settings Database tab, or the
authenticated POST /api/database/maintenance/cutover for automation) for
that. scripts/database-maintenance.mjs is admission-only and does not run it.
A backup run (runBackup in server/services/backup.js) writes to:
<destPath>/snapshots/<hostname>/<snapshotId>/
├── data/ # rsync mirror of ./data/ (minus excludes)
├── portos-db.sql # pg_dump logical dump
├── manifest.json # SHA-256 of every data/ file AND ../portos-db.sql
└── .failed # present only when snapshot creation failed
- Snapshots are namespaced by
<hostname>so one shared destination (e.g. an iCloud folder) can host backups from several federated machines withoutsnapshotIdcollisions. - Snapshot lists include every machine namespace in the destination, plus snapshots written directly under
snapshots/by PortOS versions from before hostname namespaces. Each row identifies its source, so equal timestamp IDs from different machines remain separate choices. Download, file restore, and database restore keep that source attached through preview and execution. API requests that omitsourceretain the existing behavior and select the current machine;source: "@legacy"selects the pre-namespace root. - The
manifest.jsonhashes the SQL dump too (keyed as../portos-db.sql, since the dump lives one level above thedata/tree), so a truncated or corrupt dump is detectable rather than silently trusted.
DEFAULT_EXCLUDES (in backup.js) skips ephemeral/cache data and large re-downloadable assets — all anchored with a leading / (rsync filter syntax). Two tiers:
- Non-overridable (
overridable: false): browser CDP profile, agent worktrees, cached jev training embeddings — caches with no irreplaceable user data; never backed up. - Overridable (
overridable: true): intermediate LoRA training checkpoints, cloned repos, reference repos, browser downloads, jev training corpora — re-downloadable or rebuildable; the user can disable these built-in exclusion rules from the Backup settings UI viadisabledDefaultExcludes.
Deployed LoRA weights in data/loras/ and their metadata sidecars are included by default, whether trained locally or downloaded. Promoting an earlier checkpoint copies its selected bytes there, so a backup preserves that adapter independently of excluded torch and mflux intermediate checkpoint directories. This deliberately accepts the storage cost of deployed weights. Old disabledDefaultExcludes entries for /loras/*.safetensors remain harmless; the removed default no longer needs a toggle.
data/jev/ (#7689) holds three things that are deliberately NOT one tier:
| Path | Tier | Why |
|---|---|---|
/jev/embeddings/ |
excluded, non-overridable | Frozen-encoder outputs keyed by (pair, model revision). Byte-identical on re-encode, and gigabytes. |
/jev/corpora/ |
excluded, overridable | Rebuildable by node scripts/jev-corpus.js. Overridable because the forge moves on, so someone archiving a measured adoption decision may want the exact rows the numbers came from. |
data/jev/heads/ |
NOT excluded | A trained head is a few thousand floats and is not regenerable once its corpus is stale — the queries that produced one a month ago return different rows today. It is also the only artifact here an operator made a decision about, on the strength of three measured gold-set scores. |
Everything under data/jev/ is machine-local (ADR privacy records machine-local), so a snapshot that carries a head is the only copy that survives a rebuild. Retaining it costs kilobytes; excluding it would silently discard the decision.
The effective exclude list is computed by the pure computeEffectiveExcludes() helper (unit-tested in backup.test.js). The scheduled cron handler in backupScheduler.js re-reads settings on every run, so destPath, excludePaths, disabledDefaultExcludes, and enabled all take effect on the next run without a restart. See Scheduling & status for how the cron registration itself tracks settings.
DEFAULT_EXCLUDES is rsync filter syntax — the leading / means "relative to the transfer root". Without the anchor, loras/*.safetensors also matches any loras/ directory nested anywhere under data/, silently dropping unrelated user data (e.g. brain/.../loras/). An unanchored pattern is a data-loss bug, not a style nit.
The same rule applies to user-entered Additional Exclude Paths, which is the list that is easy to get wrong: rsync matches a pattern with no leading / at every level of the tree, so typing cache/ to skip data/cache/ also drops training-runs/*/cache/, and raw/ reaches into sprite runs. The failure is silent — the snapshot reports success, it is simply smaller, and the omission surfaces only at restore. So computeEffectiveExcludes() anchors each user pattern on read (anchorUserExclude() / anchorUserExcludes() in server/lib/sharedSchemas.js:161,187), prepending / to anything that is not already anchored and is not deliberately wildcard-led.
- Anchoring happens at read time, never in storage:
settings.jsonkeeps exactly what you typed, so there is nothing to migrate and nothing is rewritten under you. The Backup tab anchors a pattern as you add it and renders the computed effective list, so the chip you see is the filter that will run. **/name/is the explicit way to ask for any-depth matching. A pattern starting with*or**is passed through unchanged — that is how you deliberately say "everycache/anywhere", rather than getting it by accident.- Patterns are bounded at the settings boundary (
backupConfigSchemainserver/lib/validation.js), and a..segment or a NUL byte is rejected rather than sanitized. The 256-character cap is measured on the anchored form — the string rsync is handed — so the boundary accepts exactly whatcomputeEffectiveExcludes()keeps, and a pattern can never be saved as valid and then silently dropped at run time.
The two overridable tiers are enforced, not advisory. A hand-edited settings.json that lists a non-overridable path in disabledDefaultExcludes is silently dropped server-side; computeEffectiveExcludes() enforces both the overridable allow-list and Array.isArray guards for hand-edited settings. The Backup tab switches describe default rule state: switching on disables that default exclusion, and switching off re-enables it. The summary counts enabled and disabled default rules, not included files.
Additional Exclude Paths is independent: toggling a default never removes custom patterns, and custom rsync patterns remain accepted even when they overlap defaults. Additional rules still apply when a default is disabled, so deployed LoRA weights can still be omitted by explicit custom patterns: *.safetensors or /lo*/ can still exclude them. computeEffectiveExcludes() produces the filter list; rsync alone decides which paths match. The UI does not predict snapshot contents.
dumpPostgres() runs pg_dump --no-owner --no-acl --clean --if-exists and returns an explicit status (no silent failure):
| Result | Meaning | Effect on backup |
|---|---|---|
{ status: 'ok', sizeBytes, tableCount } |
Dump succeeded and is non-empty | Backup ok |
{ status: 'failed', reason: 'pg_unreachable' | 'pg_dump_missing' | 'version_mismatch' | 'dump_error' | 'empty_dump' } |
Postgres is the active backend but the dump failed | Backup degraded + warning toast |
{ status: 'skipped', reason: 'not_configured' } |
The explicit file escape hatch is active — MEMORY_BACKEND=file or the backend resolved to file (dev/test, unsupported for production) |
Backup stays ok (benign) |
Key behaviors, accurate to the code:
- A failed dump degrades the whole backup.
backupStatusForPg()mapsfailed → 'degraded'; the run persistspgBackupintodata/backup/state.jsonand emits aBACKUP_DB_DUMP_FAILEDwarning through the error pipeline — even on unattended scheduled runs (which passio = null; the service falls back to the module-levelgetIo()). - A skipped dump is still benign only for the temporary
MEMORY_BACKEND=fileescape hatch (documented as unsupported for production installs). On a normal install where Postgres is active or auto-detected, an unreachable DB returnsfailed/pg_unreachable, not a green "not configured" run. --clean --if-existsmakes the dump replay cleanly into a live, already-initialized PortOS database — the common restore target — instead of erroring onrelation already exists.- A
pg_dumpthat exits 0 but produces a 0-byte file is treated asfailed/empty_dump; a non-zero exit deletes the partial file so a later restore can't trust a truncated dump. version_mismatchmeans no installedpg_dumpis new enough for the running server (pg_dumpaborts when older than the server it dumps — the common Homebrew case where an oldpostgresql@NNkeg shadows a newer running server inPATH).dumpPostgres()reads the server's major version (getServerMajorVersion()inlib/db.js) and auto-selects the closestpg_dumpwhose major is>=the server from the installed Homebrew kegs / Postgres.app bundles. SetPORTOS_PGDUMP=/path/to/pg_dumpto override the auto-discovered binary (e.g. on Linux/Windows where the keg locations don't apply).
Restore is two independent operations — restoring files and restoring the DB are separate decisions. Both are dry-run by default and validate snapshotId and an optional source namespace against path traversal before touching anything. Explicit source selections also reject symbolic-link aliases for the snapshots root, source namespace, or selected snapshot before archive or restore reads begin.
Snapshot source lifetime (#10898). A consumer that reads snapshot bytes holds a read lease on that snapshot for as long as it reads: a file restore or preview from before its restorability and integrity preflight until rsync's child closes (and, for execution, caches are reconciled); a database restore from its restorability check through dump admission and spooling; a download until the tar child closes — including after the client disconnects, since tar may still be reading. The lease is released on every exit path: success, refusal, failure, timeout and abort. Deletion needs the snapshot exclusively: explicit deletion of a snapshot in use is refused with 409 SNAPSHOT_IN_USE, automatic retention skips it (logged, and pruned by a later run), and a reader that arrives while a deletion is in progress is refused with 409 SNAPSHOT_DELETING. Deletion takes ownership before its final existence and in-progress checks, so a deletion that was admitted first cannot race a new reader. Ownership is keyed by the snapshot's canonical path, so aliases — an omitted versus an explicit current-machine source, a symlinked destination, a different letter case on a case-insensitive volume — share one owner. The lease coordinates this server's own requests and scheduled jobs; it is not a lock between machines that share a destination.
A backup run that fails after creating its snapshot directory records a durable .failed marker before releasing its .in-progress guards. Failed snapshots are never eligible for file or database restore (SNAPSHOT_FAILED); they remain downloadable so their partial files can be inspected or recovered manually. If PortOS cannot write the failed marker, it keeps the existing incomplete markers instead, which also block restore.
Interrupted snapshots (#12618). A crash, max_memory_restart or manual restart can kill a run before either marker path runs. Each .in-progress marker therefore carries the writing server process's start id. In the current machine's namespace, a snapshot whose in-progress markers were written by another process (or are the legacy empty markers) and that is not this process's active run is reported as interrupted, not incomplete: restore and download refuse it with 409 SNAPSHOT_INTERRUPTED, but it can be deleted. The next runBackup() converges each one on disk by writing .failed with the reason interrupted by restart and removing its in-progress markers; the snapshot stays interrupted. A marker whose owner cannot be read, and any snapshot in another machine's namespace, stays incomplete, because that run may still be live. Snapshots created by older PortOS versions without a manifest or failure marker retain their legacy behavior because an unmarked historical failure cannot be distinguished reliably from a genuine pre-manifest snapshot.
Before rsync can read or overwrite live data, PortOS strictly reads manifest.json, validates every manifest path and SHA-256 value, and hashes every recorded regular file in the selected restore scope. A missing, unreadable, mismatching, or unrecorded selected file refuses both preview and execution before rsync starts. Selective restore verifies only its literal selected subtree and ignores the separately handled ../portos-db.sql entry. The same preflight runs again for execution, so changing or adding snapshot bytes after a successful preview cannot bypass verification. Readable symlinks retain the hash behavior used when the manifest was created; dangling symlinks remain outside the regular-file manifest and retain rsync's archive compatibility.
Snapshots from PortOS versions that predate manifest.json remain restorable as an explicit compatibility case. Restore responses report verification.status as verified (with checkedFiles) or unverified with reason manifest_absent; the confirmation panel warns when a legacy restore cannot be verified. An existing manifest that is malformed or unreadable fails closed and is never treated as legacy absence.
After the preflight, rsync copies <snapshot>/data/ back to ./data/. Restore always passes --checksum, so rsync compares file contents even when the live file has the same size and modification time as the snapshot; equal-content files remain skippable, while differing bytes appear in previews and are restored. This applies to dry-run and live restores, including selective subdirectory restores and legacy snapshots without a manifest. dryRun: true (the default) reports what would change without writing; an optional subdirFilter limits the restore to one subdirectory.
File restore copies what the snapshot holds and leaves live files the snapshot lacks in place. One consequence is handled explicitly: a snapshot taken before the CoS config moved to its own file (#6182) carries its CoS settings inside cos/state.json and has no cos/config.json. When such a restore lands that legacy slice, the live cos/config.json (newer than the restore point) is moved aside to cos/config.pre-restore-<timestamp>.json and the settings are recovered from the restored state.json; otherwise the newer file would keep winning every read.
A snapshot taken before the agent records moved into PostgreSQL (#12738) also carries an agents map inside cos/state.json. A file restore does not bring those records back: agent records are cos_agents rows, restored with the database dump, and the CoS runtime ignores (and drops on its next save) an agents map it finds in state.json.
Historical file-to-database metadata (#12397). On a PostgreSQL install, full and selective file restores refuse selected legacy metadata with 409 BACKUP_LEGACY_DB_METADATA before preview or execution starts a transfer. The diagnostic names only affected domains: universes (record index.json and type-level config.runs), pipeline issues/series, Story Builder, Writers Room (folders.json, exercises.json, work manifest.json), and Creative Director projects. These records now live in PostgreSQL. A live domain import marker can suppress re-import after file restore; restoring the older file-migration ledger does not reset that separate marker. Clearing markers is unsafe too: the importers preserve existing rows with ON CONFLICT DO NOTHING, so they cannot provide replacement-restore semantics for overlapping IDs.
Keep the original backup intact. Copy it into a disposable offline install running the compatible historical version, upgrade that isolated install through the file-to-database transition, and verify the imported records and linked files there. Make a current-format file and PostgreSQL backup from the converted install, then use the normal verified restore workflow. Do not convert against the live database or delete its markers. Current file-primary siblings (such as manuscript reviews, draft Markdown and media assets) and parked .imported recovery evidence remain eligible for file restore. Selecting one of those siblings is allowed even if another part of the snapshot contains legacy metadata.
A full restore also installs the snapshot's migrations.applied.json, so the next start re-runs every file migration the snapshot predates. A snapshot taken before migrations moved out of data/ (May 2026) carries that ledger at migrations/.applied.json instead; the transfer lands it beside the live migrations.applied.json, which the runner would then keep, so a full restore adopts it by hand after the transfer: the live ledger is parked as migrations.applied.json.pre-restore-<timestamp> and the restored one takes its place. Preview and result carry restoredLegacyLedger ({ status, parkedAs }, pending in a preview and adopted after execution; unreadable when the snapshot's legacy ledger does not parse as a list, in which case the live ledger is kept); the widget warns before restore and asks for a restart afterwards. A snapshot that also carries a current-path ledger is left to the transfer. That re-run only upgrades restored files for migrations gated on their input. The store-split migrations (059 for media collections, 200 for the Brain stores) gate on their output, a type index already at schema v1, and file restore leaves the live index in place, so their re-run is a no-op over a restored monolith. Restore therefore converts those stores itself (#12626): when the scope covers a store (full restore, brain, brain/<type>.json, media-collections or media-collections.json) and the snapshot carries its monolith (brain/<type>.json, media-collections.json) without <store>/index.json, a successful transfer moves the live <store>/ directory to <store>.pre-restore-<timestamp>/ (and a live .bak-200/.bak-059 backup beside it), runs the frozen split migration against the install, and invalidates the Brain caches. The restored records, tombstones included, then load immediately, for folder restores too. A full restore whose snapshot ledger lacks the split migration also lacks the migrations an upgrade ran before it (080/081/190 for the Brain stores, 021/038 for media collections), so it does not run the split alone: after adopting the restored ledger it runs that ledger's pending migrations through the split with the real runner (runMigrations({ through })), in order (#12813). It also parks every live Brain type directory the snapshot has no <type>/index.json for, because an earlier migration can create the monolith (081 builds brain/inbox.json from inbox_log.jsonl). The result matches an in-order upgrade of the snapshot, and migrations after the split run on the next start. Preview and result carry restoredLegacyMigrations ({ status, through, names }, pending in a preview and applied after execution). Folder restores never replay. A snapshot that has the split layout plus a stray monolith (setup-data re-seeds those after an upgrade) is current and is not converted. A monolith the split would refuse (unparseable, or a malformed records container) is restored as-is and never parks the live store. Preview and result list these stores in restoredLegacyStores ({ store, legacyFile, status, parkedAs }, with status pending in a preview and converted or unreadable after execution); the widget warns before restore and names the parked directory afterwards. A folder restore (subdirFilter) keeps the live ledger, so restored files can come back in a shape older than migrations no runner will revisit. Its preview and result therefore carry ledgerGap: { count, names } listing the migrations the live ledger records and the snapshot's lacks, or null when either ledger is missing or unreadable. A full restore omits the field. The widget warns on a non-empty gap and recommends a full restore, but does not refuse: a folder no migration touched restores correctly. The quota-burn plan is converted on the spot: a restore covering cos/quota-burn.json (full, cos, or that file) runs any legacy jobType steps through the same conversion as migration 359 (reconcileRestoredQuotaBurnPlan), reusing each step's deterministic job-burn-<family>-<step> custom job, so a repeated restore creates no duplicates.
Every nonzero rsync exit fails a restore, including 24. A backup tolerates exit 24 ("some source files vanished") because it reads the live data tree, where a file deleted mid-scan is normal. A restore reads a snapshot that must not change under it, so a vanished source file means live data may now mix restored and older files: preview and execution both reject it. An execution failure is reported as a partial restore — the error says some files may already have been overwritten because file restore is not transactional — and caches (settings, Brain, media index) are reconciled before the error is returned, exactly as for any other failed transfer.
data/database-authority.json is backed up as cutover evidence but is never installed by a file restore. It records which database backend a completed cutover retired on the machine that ran it, so restoring it from another machine's snapshot (or from an older snapshot after a reverse cutover) would make PortOS refuse its own healthy database with DATABASE_RETIRED_BACKEND, including after a restart. Every file restore therefore excludes it, in preview and execution alike, and the integrity preflight ignores it too. The destination's existing file stays byte-for-byte as it was: an absent record stays absent and a damaged one stays fail-closed. The same preservation covers the other machine-local authority and recovery records — peer-execution/, peer-execution-catalog.json, peer-execution-grants.json, peer-execution-authority.json, peer-execution-recovery.jsonl, workflow-maintenance/ (the graceful-maintenance journal and abandoned-run receipts) and backup-admission/ (which snapshots also skip) — so a snapshot's copy is evidence only (see STORAGE.md). Other records in the snapshot restore normally. Selecting any of these paths, including database-authority.json itself, is refused with BACKUP_RESTORE_MACHINE_LOCAL before anything is transferred. This does not touch an active maintenance journal (database-maintenance/): its recovery semantics are unchanged, and another machine's cutover endpoints are never imported. To change which backend is authoritative, run a database cutover.
data/instances.json holds this install's federation identity (self.instanceId), its peer list and each peer's pair secret. A shared destination lists every machine's snapshots, so installing another machine's file would give two live installs one identity: both would claim the same CoS tasks, sign pushes with the same pair credentials and advance the same per-peer sync cursors (#12596). Before verification, a restore whose scope reaches instances.json (a full restore or subdirFilter: 'instances.json') compares the snapshot's self.instanceId with the live file's:
- Same identity, or no live identity (this machine's own older snapshot, or a never-federated install): the file restores as before.
- Different identity: the live file is kept byte-for-byte. It is excluded from the rsync transfer, the manifest selection and the scope inventory exactly like the machine-local records above, so preview and execution agree, and both report
identityPreserved: { liveInstanceId, snapshotInstanceId }. Every other file restores normally. An unreadable live file counts as an identity (liveInstanceId: null), since its owner cannot be known; an unreadable snapshot file reportssnapshotInstanceId: null. adoptIdentity: trueinstalls the snapshot's file anyway and reportsidentityAdopted. Use it only to replace a retired machine. The restore panel shows the mismatch with an unchecked "Adopt the snapshot's identity — only if the machine that made it is retired" checkbox; toggling it requires a new preview.
Any restore that does not install the snapshot's instances.json (including a snapshot that had none when checked) excludes it from the transfer, so bytes that appear later cannot reach the live file. Because the choice is made before verification, execution re-checks it under the identity writer lock immediately before rsync starts; if the live identity changed in between (a first pairing, for example), the restore is refused with 409 BACKUP_RESTORE_IDENTITY_CHANGED before any file is written. Whenever a restore installs instances.json, the memoized instance id is dropped after the transfer (even a partial one), so getInstanceId() and peer request headers follow the restored identity without a restart.
Replays the snapshot's portos-db.sql into the live database via psql -v ON_ERROR_STOP=1 --single-transaction, so the SQL replay is atomic: a failed replay rolls back.
Client/server compatibility (#9925). Preview and execution check the target PostgreSQL major version. A PG17-client dump made from PG16 can be replayed to PG16: the private replay copy omits only the exact top-level SET transaction_timeout = 0; header, which PostgreSQL 17 introduced. PG17+ targets retain it. COPY rows, quoted functions/strings, comments, and psql \restrict / \unrestrict commands remain intact. Source bytes, manifest hashes and recovery receipts are unchanged; extension normalization and transactional rollback remain in effect. An unresolvable target major returns restore_compatibility with a connectivity/version-check instruction before mutation. This narrow compatibility fix does not make arbitrary newer SQL features portable to older servers.
Dump admission (#8782). Because the replay starts by resetting every application table, a dump that parses but stops early would commit an empty database — ON_ERROR_STOP cannot see a file that simply ends between complete statements, and historical snapshots carry no checksum to catch it. Before preview or execution perform reset preflight or mutations, PortOS streams the dump once and requires the plain pg_dump envelope: the terminal -- PostgreSQL database dump complete block as the last content (only the \unrestrict line newer pg_dump writes may follow it) and the CREATE TABLE definitions for memories and memory_links, which every PortOS dump has contained. Tables introduced later are not required, and a complete dump of empty tables is valid. A header-only or truncated dump is refused as dump_incomplete, and a read failure as dump_unreadable — never as an empty successful restore. The same streamed read supplies the manifest checksum. For execution it also writes the bytes it checked to an owner-only copy in a fresh portos-restore-* directory under the OS temp dir, and psql replays that copy rather than the snapshot path, so a dump that changes on the backup media after admission cannot be what gets restored. The copy is removed when the restore finishes, whatever the outcome; staging it needs free temp space about the size of the dump. After replay commits, PortOS forces the current additive schema upgrades and then runs ordered DB migrations using the restored schema_migrations ledger. Already-applied migrations are skipped. Success is returned only after both phases finish and peer sync is repaired (below); dry-run performs neither replay nor schema changes.
| Result | Meaning |
|---|---|
{ status: 'ok', dryRun, sizeBytes, tableCount } |
Dry-run report, or a successful real restore (which adds syncCursorsRewound, the number of peers rewound) |
{ status: 'skipped', reason: 'no_dump' } |
No portos-db.sql in the snapshot (or 0 bytes) |
{ status: 'skipped', reason: 'not_configured' } |
Real restore requested but Postgres is unreachable — refuses to half-restore |
{ status: 'failed', reason: 'manifest_unreadable' } |
An existing manifest.json is corrupt or unreadable — choose another snapshot or repair the backup media before retrying |
{ status: 'failed', reason: 'manifest_mismatch' } |
Snapshot's portos-db.sql hash disagrees with manifest.json — dump considered untrustworthy |
{ status: 'failed', reason: 'dump_unreadable', error } |
portos-db.sql could not be read, or its private restore copy could not be written — refused before any reset |
{ status: 'failed', reason: 'dump_incomplete', error } |
The dump lacks the pg_dump completion marker or a core PortOS table (header-only or truncated) — refused before any reset |
{ status: 'failed', reason: 'restore_compatibility', error } |
Target PostgreSQL major cannot be established — repair the version check and retry preview before execution |
{ status: 'failed', reason: 'restore_journal', error } |
The recovery journal could not be written — refused before any reset |
{ status: 'failed', reason: 'backup_snapshot_busy', error } |
A backup cut is active, or admitted asset publications did not drain — refused before the recovery journal or any reset; retry later |
{ status: 'failed', reason: 'restore_error' | 'timeout', error } |
psql replay failed (stderr captured) and was proven rolled back from its receipt (below) |
{ status: 'failed', reason: 'restore_commit_unknown', error, recovery } |
psql did not report success and the receipt could not settle whether the replay committed — recovery pending |
{ status: 'failed', reason: 'restore_schema_reconciliation', error, recovery } |
The dump committed, but current schema recovery failed; not rolled back, recovery pending |
{ status: 'failed', reason: 'restore_catalog_reconciliation', error, recovery } |
The dump committed and the schema recovered, but forced catalog payload migration or universe-tag repair failed — recovery pending |
{ status: 'failed', reason: 'restore_sync_resync', error, recovery } |
The dump committed and the schema recovered, but peer sync could not be repaired (below) — recovery pending |
{ status: 'failed', reason: 'restore_recovery_release', error, recovery } |
Repair finished but the journal could not be cleared — recovery pending |
{ status: 'failed', reason: 'restore_recovery_pending', error, recovery } |
An earlier restore is still awaiting recovery; preview and replay are refused |
Peer sync repair (#8710). Memories and the seven Catalog tables federate through per-stream feed positions. A restore breaks both directions: this install's per-peer pull cursors (data/instances_sync_cursors.json) still point past rows the restore discarded, and the dump rewinds each <table>_sync_feed_seq, so new rows would reuse positions peers have already passed. Inside the maintenance window, the restore records each feed sequence before replay. After reconciliation, it sets each sequence back to at least that value and rewinds every peer's memorySeq and catalogSeqs to 0. Brain cursors and snapshot checksums are left alone. The next sync replays each peer's streams through the idempotent last-writer-wins apply paths. A sync already in flight during the restore does not save its pre-restore cursor positions. Dry-run and failed restores change neither cursors nor sequences.
Committed-restore recovery (#9725). A replay that commits but whose repair then fails must neither reopen ordinary writes nor be replayed again — the next sequence value would reuse positions peers already passed, inbound cursors would still skip discarded rows, and a repeated restore would also delete anything accepted after the first replay. So, inside the maintenance window and before the destructive replay, the restore publishes a durable journal at data/database-restore-recovery.json holding a fresh operation id, the admitted dump's SHA-256, the ORIGINAL feed-sequence positions and the pending stage (replaying). The psql replay appends one restore_receipts row for that operation id after the dump, inside the same transaction, and runs with application_name = portos-restore-<id>.
- While the journal exists, every ordinary database operation in the server is refused with
503 DATABASE_RESTORE_RECOVERY(server/lib/db.js); only the maintenance context that owns that operation may inspect or repair. The fence survives restart. psqlexit 0 means the replay committed (stagerepairing). Any other outcome — an SQL error, a timeout kill, a lost connection atCOMMIT— is settled from the receipt: no liveportos-restore-<id>session and no receipt is a proven rollback, which clears the journal and reopens admission with current rows intact; a receipt is a commit; anything else (a replay session still alive, an unreachable database) stays fenced asrestore_commit_unknown.- Repair runs forced schema upgrades, ordered migrations, feed-sequence flooring at the recorded positions (never re-read after the replay) and the inbound cursor rewind. All steps are idempotent. Admission reopens only after every step succeeds, the journal is removed, and its parent directory is synced.
- If journal unlink fails, the durable record remains pending. If unlink succeeds but directory sync fails, the journal instance retains the operation and original feed positions in memory: status and ordinary database admission stay pending, and same-operation recovery retries finish directory sync. On a fresh process, a surviving journal invokes idempotent repair; if deletion persisted, completed repair needs no destructive replay. This process-local retry cannot provide stronger crash guarantees than the filesystem.
- Resume, never repeat. Settings → Backup shows the pending operation with a Resume recovery action (
POST /api/backup/restore-db/recoverwith the operationid), andGET /api/backup/statusreports it asrestoreRecovery. Restarting PortOS resumes it automatically before any route, scheduler or writer loads (server/start.js); if recovery still fails, boot is refused with the reason in the server log and the fence stays closed. Recovery never runs the reset or the dump again, and starting another restore is refused (restore_recovery_pending) until this one finishes. - An unreadable or damaged journal fails closed: the database stays fenced and boot is refused. Inspect the file and the logs; do not delete it unless you have established how the restore ended (with the database reachable,
SELECT * FROM restore_receipts WHERE operation_id = '<id>'answers whether it committed).
Reconciliation can partially apply upgrades; it is separate from the completed replay transaction, which is why it is retried rather than rolled back.
A non-dry-run restore requires a reachable DB first (checkHealth()), so a restore never half-applies against a down database.
Separate from snapshot restore: server/routes/database.js can copy data between the native (port 5432) and Docker (port 5561) Postgres backends. It exports the active backend with pg_dump, ensures the target backend has the portos role/database/extensions, then imports under ON_ERROR_STOP=1 --single-transaction. POST /api/database/export produces an on-demand dump under data/db-dumps/. These power the Database settings tab's mode-switch/sync flows; they are independent of the rsync snapshot backups above.
Left unbounded, snapshots/<hostname>/ grows forever — every prior full DB dump stays reachable, so a record deleted from the live database (including privacy_vault_records, privacy_consents, and privacy_broker_cases) remains restorable from an old snapshot indefinitely. server/lib/backupConfig.js resolves a per-source retentionCount, and runBackup() prunes with it after a successful database dump, or when the explicit file-backend escape hatch skips the dump (pruneOldSnapshots() in backup.js). A degraded run whose database dump failed skips pruning and logs a warning, preserving older snapshots until a later dump succeeds.
- New installs default to 30 completed snapshots per source. The default ships as
backup.retentionCount: 30indata.reference/settings.json, copied only into an install that has nodata/settings.jsonyet — see the comment onresolveRetentionCount()for why this, not a migration, is what keeps an existing install from silently losing its archive. Operators choose 1–365, or Unlimited, from the Backup settings tab. retentionCountabsent or explicitlynullboth mean unlimited — no completed snapshot is ever pruned. This is why an install that predates this setting keeps every completed snapshot until the operator saves a choice: it has no stored value, and absence resolves to unlimited, not to the new-install default. Unlimited retention still removes interrupted snapshots older than the newest healthy completed one (#12618), including ones a pre-#12618 version left with an empty.in-progressmarker, so the first successful run after upgrading clears those partial copies.- Pruning runs only after a run reaches a completed snapshot with a usable database dump (
pg_dumpsucceeded, or was explicitly skipped by the file-backend escape hatch). A degraded run whose database dump failed keeps older snapshots and reports zero pruned snapshots; retention resumes after a later successful dump. A run that fails before completion never prunes, and a prune failure is logged without failing an otherwise-successful backup. - Pruning is scoped to the CURRENT machine's namespace (
snapshots/<hostname>/) and only ever deletes snapshots whosesnapshotState()reports neitherincompletenorfailed, plus interrupted snapshots older than the newest healthy completed one. Interrupted snapshots never count towardretentionCountand are pruned even under unlimited retention, since they can never be restored or downloaded. It never touches another machine's namespace in a shared destination, the legacy pre-namespace root, or an in-progress/ordinary failed snapshot — those stay until the operator deletes them explicitly. It also skips a snapshot that a restore or download is still reading (see Snapshot source lifetime); the skipped snapshot is not counted as pruned and becomes a candidate again on a later run. - Unreadable or structurally invalid manifests are protected in addition to the retention count. Retention uses the same field, count, path and SHA-256-shape validator as file restore, without hashing old payloads. Existing JSON
null, arrays, malformed objects and invalid entries are not legacy absence. They never displace structurally valid completed snapshots, even when an incorrect clock gave a protected snapshot a future-dated ID. Snapshot IDs determine age; manifest timestamps do not change that ordering. Legacy completed snapshots with no manifest remain eligible for retention as before. DELETE /api/backup/snapshots/:snapshotId?source=<source>(backup.deleteSnapshot()) permanently removes exactly the selected source/ID pair, immediately — there is no undo and it is never automatic. It sharesresolveSnapshotPath()'s path-traversal and symlink guards with restore/download, and refuses a snapshot that is still being written (SNAPSHOT_INCOMPLETE) or that a restore or download is still reading (SNAPSHOT_IN_USE); unlike restore, a.failedor interrupted snapshot is deletable. The Backup settings tab's snapshot history exposes this as a per-row delete action behind a confirmation dialog.
- Daily backups are driven by
backupScheduler.jsvia thebackup-dailycron event;getNextRunTime()reports the next run. - The registration tracks settings — no restart needed.
syncBackupSchedule()runs at boot and on every settings save (subscribed tosettingsEvents'settings:updated), so enabling backups or settingdestPathwhen scheduling was previously inactive registers the cron immediately, editingcronExpressionre-registers it, and disabling backups (or clearingdestPath) cancels it. A save that doesn't change the registration inputs (cron expression, timezone, active/inactive) is a no-op —destPathand the exclude lists are re-read by the handler per run, so changing them never churns the registration. GET /api/backup/statussurfaces the persisted state including the lastpgBackupoutcome, so the Backup settings tab shows whether the last DB dump succeeded, its size, and table count.
The backup settings slice is stored sparsely — an install where the user only ever typed a destination has no enabled and no cronExpression on disk. server/lib/backupConfig.js is the single module that says what those omissions mean, and every consumer resolves through it:
| Stored | Effective |
|---|---|
enabled absent |
enabled — an omitted toggle has never meant "off" on the server, and changing that would silently stop nightly backups on existing installs |
cronExpression absent or blank |
0 0 * * * (midnight, in the user's timezone) |
destPath absent or blank |
nothing is scheduled, whatever enabled says |
retentionCount absent or null |
unlimited — nothing is pruned. A NEW install ships an explicit 30 in data/settings.json (see Retention & deletion); an existing install's absence is never reinterpreted as that default. |
GET /api/settings projects these effective values over the stored slice, so the Backup settings tab renders exactly what the scheduler will do. The client owns no fallback of its own — it used to read the same sparse config as "disabled at 02:00" while the scheduler read it as "enabled at midnight", so saving an unrelated preference wrote that misreading back and cancelled a live schedule (#6632). If a settings response ever arrives without a resolved schedule the tab shows a load error instead of a form, rather than saving invented values.
This is read-time resolution only: nothing on disk changes, sparse configurations stay valid, and older clients can keep submitting the same partial shape.
- Storage Classification Contract — which data lives in Postgres vs files (and therefore which half of a snapshot captures it).
docs/superpowers/specs/2026-06-05-verified-pg-backup-design.md— design rationale for verified, restorable DB backups.
Database restore is a full replacement, not a merge. It removes current PortOS tables, functions and sequence state before replaying the snapshot, including tables introduced after the backup. Current schema upgrades and ordered migrations then recreate newer tables consistently without retaining post-snapshot records. The public namespace's owner and permissions are retained.
Preview performs read-only ownership/object/dependency checks. Unknown objects
or external dependencies refuse the restore; the reset uses RESTRICT, never
an unrestricted cascade. Reset and dump replay share one PostgreSQL transaction
with ON_ERROR_STOP: a replay failure restores the previous rows and constraints.
Schema reconciliation follows the commit; a reconciliation failure explicitly
reports that replay committed and recovery still needs attention. Application
database operations drain before reset; new operations receive a temporary
maintenance error until replay and reconciliation finish (including failures).
Full live file restores and selective settings.json restores join the settings
write queue. Previously admitted mutations finish before transfer starts; later
mutations wait through transfer and cache reconciliation, then read the restored
file as their base. Partial transfer and reconciliation failures release the
queue while retaining the existing restore diagnostics. Malformed restored
settings still invalidate the cache rather than broadcasting empty defaults.
Dry runs and unrelated selective restores do not acquire this settings boundary.
A full live restore acquires boundaries in this order: shared snapshot cut
(asset publications drained), task schedule, settings, CoS configuration, CoS
runtime state, media model registry, Apple Health day-file admission. A
selective restore acquires the same order minus the boundaries its scope does
not touch; the snapshot cut is always held.
Each boundary stays held until everything inside it — transfer and cache
reconciliation, including after a partial transfer failure — has settled, so the
settings queue remains held through CoS reconciliation. The order is declared once
in LIVE_FILE_RESTORE_OWNERS (server/services/backup.js); insert a new
boundary there rather than nesting another wrapper. Snapshot integrity is
verified before any boundary is acquired, and a dry run acquires none of them.
The peer-execution identity fence is shorter-lived: it wraps only the rsync
transfer, never reconciliation, and only when the restore installs
instances.json (see Federation identity is kept unless adopted;
decided before verification, so a preview reports the same choice).
A restore callback must never call a queued settings write API; cache reload reads directly and does not re-enter the queue.
Full live file restores, cos restores, and exact cos/task-schedule.json
restores join the schedule's existing write queue after the shared snapshot cut
and before settings/CoS ownership. Schedule mutations can consult CoS state, so
reversing that order could deadlock. Already admitted writes finish before
transfer; later writes read the restored schedule and preserve untouched task
settings, prompts, and templates.
The queue stays held through compatibility normalization and the schedule-change notification, including after a partial transfer failure. Normalization reads and saves directly inside that turn without re-entering the queue. Transfer errors retain partial-overwrite diagnostics; reconciliation errors are surfaced without discarding the transfer failure. Dry runs and unrelated scopes do not acquire the schedule boundary.
A full live file restore or selective cos restore requires the CoS daemon and
Persistent Mind to be stopped and active agents to be finished or stopped. The
server rejects unsafe or unreadable ownership state before rsync starts, with
COS_RESTORE_BUSY. Pausing the daemon alone is insufficient.
The restore drains configuration writes, then runtime-state writes, and holds both queues through the transfer and cache reload. Both CoS caches are reloaded and the normal configuration-change event is emitted even after a partial rsync failure. Subsequent partial settings saves preserve restored fields. Transfer errors still report that files may have been overwritten; a cache-reload failure requires restarting PortOS before using CoS. Dry runs and selective restores outside the CoS state/config scope do not acquire this boundary.
Full live file restores, health restores, and single-day health/... restores
own Apple Health mutation admission (withLiveHealthRestore in
server/services/appleHealthIngest.js). JSON ingest, XML import batch flushes,
and health archive capture and removal each take admission before their day's
write queue and hold it until that day's read-modify-write (or capture/removal)
settles, so no cycle can carry a pre-restore read across the transfer.
From the moment the restore is requested, new day cycles wait. Cycles already
admitted finish first; then rsync runs; then health caches are invalidated and
admission reopens — on success, partial transfer failure, or reconciliation
failure alike. Waiting imports then read the restored day files, so points the
snapshot recovered are preserved. An XML import takes admission per day, so a
restore can land between two of its days. Archival releases admission while
tar encodes; its hash-conditional removal retains any day whose bytes the
restore changed. Outside a restore, different days still import concurrently.
Health admission is acquired last, so a restore refused earlier (for example by
COS_RESTORE_BUSY) never pauses imports. Dry runs and unrelated selective
restores do not acquire it.
While the server is still running, GET /api/database/maintenance/status reads the journal without querying PostgreSQL or launching shell probes. It uses the ordinary instance authentication gate (authentication remains optional) and returns { stage: "idle", fenced: false } when no operation exists, or the operation's id, stage, source/target mode names, and fenced: true. Connection identities and coordinator credentials are never returned. Responses are not cacheable; damaged or unreadable state returns HTTP 503 with DATABASE_MAINTENANCE, never idle. A verified stage alone still reports fenced: it does not authorize writers or prove admission has reopened. Once the server has stopped or its boot fence refuses startup, use the local status command below; this endpoint does not bypass the boot fence.
The persistent maintenance boundary is a prerequisite for coordinated offline migration (#8805). It does not migrate data, change saved mode, stop existing writer processes, or prove a target cutover. In particular, entering maintenance is not permission to invoke an uncoordinated SQL import or backend migration.
From the install root, inspect the journal with status. begin is a
low-level, admission-only command — it does not start a migration. It
immediately fences normal database work, launches no coordinator or worker, and
will never progress by waiting; recover cannot start it either, because
there is no coordinator to resume. Use Settings (Database tab) or
POST /api/database/maintenance/cutover to migrate. Run begin only when you
deliberately want the bare fence:
node scripts/database-maintenance.mjs status
node scripts/database-maintenance.mjs begin native docker
# Or, when the saved source mode is Docker:
node scripts/database-maintenance.mjs begin docker nativeIf an operation was created by begin alone (status shows stage accepted
and no coordinator), cancel it with the command below, using the unchanged
source configuration, before starting the complete cutover. The cutover is
refused while that ownerless fence exists.
The command returns the operation ID and direction. Plan for downtime: new pooled database operations, database administration mutations/exports, and CoS spawn admission are refused immediately, and both managed server and runner refuse normal boot while fenced. Admission is the synchronous fence check: work admitted just before publication may still obtain a connection or spawn afterward. Already-admitted transactions may finish; this boundary alone is not a drained snapshot boundary. The offline coordinator must still stop/drain all owned writers and validate live/spawning work before export. A process-local restore callback cannot bypass this persistent fence.
If no transfer has started and saved source configuration is unchanged, cancel using that exact ID:
node scripts/database-maintenance.mjs cancel <operation-id>Cancellation supports only the initial accepted stage, preserves the journal in the machine-local cancelled archive, and never switches mode or reverses direction. Restart managed processes through the existing PM2 ecosystem workflow after cancellation if their normal boot was refused. CLI configuration follows the ecosystem's environment precedence; run from the same configured operator environment. Changed source configuration, a different operation ID, an unknown stage/version, and a competing operation are refused.
An incomplete publication or interrupted cancellation remains fenced. Do not remove the active directory, steal its cancellation claim, or guess a backend from .env: retain the journal for recovery. There is no rollback, force or skip command. Cancellation clears an ownerless accepted fence; node scripts/database-maintenance.mjs recover <operation-id> is a different case: it only resumes the same recorded, coordinator-owned operation (identity preserved) after its worker exited. The offline transfer, its retained data/db-dumps/portos-maintenance-<operation-id>.sql recovery dump, mode commit, verified restart and release are described in offline transfer and mode commit, verified restart, and release; Settings (Database tab) surfaces the interrupted state and its Resume control from the same journal. data/database-authority.json and data/database-maintenance-completed/ are local records of a finished cutover. Snapshots keep them as recovery evidence, but a file restore never installs database-authority.json (see Database authority is not restored), so restoring another machine's or an older backup can neither lift nor impose the retired-backend guard. Normal startup and migration must not be re-enabled by treating this preparatory operation as a completed cutover.
Coordinator stage publication uses immutable, atomically linked records. A same-operation retry with the existing owner token can confirm its last transition after a lost response; abandoned temporary files do not advance the stage, and a delayed retry cannot overwrite later progress. Interrupted claims from the older publication protocol still fail closed. This does not permit replacing a coordinator, reopening admission, or retrying SQL import: those require the offline recovery protocol.
The managed server entrypoint is server/start.js. Normal startup checks the
persistent fence before importing the application graph, so routes, migrations,
schedulers, and writer modules cannot initialize while maintenance is active.
The CoS runner retains its separate boot fence.
For an operation already at verifying or verified, a recovery operator can
run a read-only target diagnostic from the intended target launch environment:
node server/start.js --verify-database <operation-id>Supply the recorded target's PGHOST, PGPORT, PGUSER, and PGDATABASE through
that environment, and credentials through PGPASSWORD. This direct Node command
does not load .env; use the same resolved values the PM2 ecosystem would pass
to the server. It checks the actual pool's captured connection settings against
the recorded target before checkout, then checks database/user identity and the
required memory/catalog schema in one read-only transaction. A stale source
port, wrong operation, earlier stage, unavailable database, incomplete schema,
or changed/damaged journal refuses the diagnostic. Raw connection errors and
credentials are omitted from its response.
Success returns targetHealthy: true and fenced: true, then closes the pool
and exits. Repeating the command repeats the probe without changing any journal
stage. It never imports the application, clears the fence, rewrites saved mode,
or resumes writers. This is not completed cutover or proof of a later ordinary
restart. The offline coordinator still must own shutdown/drain, transfer,
interruption recovery, and a verified admission-release handshake (#8816).
Do not advance journal stages manually to make this diagnostic run; unsupported
or damaged operations must stay fenced with both database copies preserved.
A full live file restore or a selective media-models.json restore fences the
cached media registry (withLiveMediaModelsRestore in server/lib/mediaModels.js).
From the moment the restore is requested, availability toggles, adds, patches and
removes are refused with MEDIA_MODELS_RESTORE_BUSY before any disk or cache
change; competing restores are serialized. After the transfer — including a
partial rsync failure — the registry is reloaded from the resulting file before
the fence is released, so a later toggle can no longer write the pre-restore
registry back over the restored one. If the reload cannot read the file, edits stay
refused with MEDIA_MODELS_UNAVAILABLE until a later restore reconciles it or
PortOS restarts. Dry runs and unrelated selective restores do not acquire this
boundary.