`blobs_consistency` probed `blob_exists` once per row and, under `?deep=true`, read and re-hashed every blob. `backend_consistency` already reports the same `blob_missing_from_backend` from its merge-join — so the probe was duplicated work that found strictly less (a DB walk cannot see backend-only orphans by construction) at N round -trips instead of one enumeration. Every scheduled sweep paid for it. All three physical checks move to `backend_consistency`: * `blob_missing_from_backend` was already there; the duplicate is gone. * `blob_corrupted` / `blob_unreadable` hook the matched arm of the merge-join, which holds exactly the key pairs worth reading. Guarded by `in_range` so a pair past the horizon is not read twice, and `params.deep` is persisted on a fresh run and read back on resume so a paused deep scan does not silently continue shallow. Deep mode belongs there because it is backend work end to end: the only DB input is the hash. Keeping it in `blobs_consistency` forced that tenant to carry a backend for one flag. What remains is the half that needs no backend: `refcount_mismatch` and its repair. The constructor drops from five parameters to two — no backend, no storage_entries, no storage_path_fallback — and `?storage=<name>` / `?deep=true` are now inert there, which the job description says outright. `affected_files` is needed by both tenants, so it moves to a shared `blob_diagnostics` module rather than being copied. `PROBED_STORAGE_PARAM` moves to `backend_consistency`: it was defined in `blobs_consistency` and re-exported, which is backwards once the DB-only tenant has no entry to scope. The create-grace window goes with the probe — it existed to avoid flagging a blob whose bytes had landed before its row, and the refcount comparison reads one consistent snapshot. Known cost: `backend_consistency` returns `backend_unenumerable` on Azure and mid-migration, so on those configs missing bytes now go unreported where the per-row probe caught them. That argues for the Azure enumeration impl, not for keeping the probe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 KiB
Plan — Multi-entry storage config + name-selected active backend
Context
Today OxiCloud has a single storage backend, configured via a flat set of env
vars (OXICLOUD_STORAGE_BACKEND, OXICLOUD_S3_*, OXICLOUD_STORAGE_ENCRYPTION_KEY,
…). The admin panel has a second, parallel storage-config surface backed by
admin_settings.storage.* rows, used by the migration tool to pick a target.
Priority is env > DB > defaults, so the DB config effectively acts as a
staging area for "what the migration should copy INTO" but never wins at boot.
Two chronic problems fall out:
-
Split-brain config. Admin edits DB via the panel; app boot ignores DB. Migration completes; live backend hasn't moved. Admin has to remember to copy env vars into
.envand restart. Two sources of truth for the same setting. Cutover is a manual multi-step flow; users routinely get it wrong. -
Migration data-loss window on concurrent writes. The copy walks
storage.blobsin hash order. A blob whose hash is lex-lower than the current cursor, written to source AFTER migration passed it, is never copied to target.passed=true, findings=0completion does NOT guarantee target has every blob. Silent. -
Migration target selection is fragile. DTO passes the whole S3 config at trigger time; secrets sit plaintext in
admin_settings. Any future pluggable-storage story compounds this (Azure, GCS, WebDAV-as-source, …).
This plan replaces the split-brain model with a single-source-of-truth architecture:
.envdeclares N named storage entries (immutable per-deploy).admin_settings.storage.active_backend_nameholds ONE row — which named entry the app currently runs on. That's the whole runtime config.- Migration is the atomic transition from one active entry to another. Server is put in read-only mode for the copy window; on completion, the active pointer flips; a restart cuts over.
Named entries also solve two adjacent problems Ed flagged during design:
- Per-entry encryption keys enable
local (raw) → s3 (encrypted K1)moves ANDs3 (K1) → s3-new-bucket (K2)key-rotation moves. ?storage=<name>onblobs_consistency/backend_consistencylets operators audit any registered entry (target verification, pre-decommission check, etc.), replacing the sample-basedverify_migrationendpoint with a full-walk audit.
Design decisions
Named entries in .env — explicit allowlist
OXICLOUD_STORAGE_ENTRIES=local_main,s3_prod
OXICLOUD_STORAGE_local_main_BACKEND=local
OXICLOUD_STORAGE_local_main_ROOT_DIR=/data
OXICLOUD_STORAGE_s3_prod_BACKEND=s3
OXICLOUD_STORAGE_s3_prod_S3_BUCKET=my-bucket
OXICLOUD_STORAGE_s3_prod_S3_ENDPOINT_URL=https://s3.example.com
OXICLOUD_STORAGE_s3_prod_S3_REGION=us-east-1
OXICLOUD_STORAGE_s3_prod_S3_ACCESS_KEY=...
OXICLOUD_STORAGE_s3_prod_S3_SECRET_KEY=...
OXICLOUD_STORAGE_s3_prod_S3_FORCE_PATH_STYLE=true
OXICLOUD_STORAGE_s3_prod_ENCRYPTION_KEY=<base64-32-bytes>
- Explicit
_ENTRIESallowlist — order-independent, admin-authored names. Env-pattern-matching was considered and rejected: too fragile (someone typosOXICLOUD_STORAGE_s3_prud_BUCKET, gets a silently-registered ghost entry). Explicit list forces a declaration. - Names are admin-chosen strings matching
[a-z0-9_-]{1,32}, unique within the list. Parsed at boot; unparseable → fail-fast with the offending name in the error. - Order does not matter — DB says which is active. Reordering entries in
.envnever changes runtime behaviour.
Legacy flat-var interaction — three states, one is fail-fast
Multi-entry lives alongside the pre-existing single-backend flat vars
(OXICLOUD_STORAGE_BACKEND, OXICLOUD_S3_*, OXICLOUD_AZURE_*,
OXICLOUD_STORAGE_ENCRYPTION_KEY, _ENABLED). The parser resolves the
interaction as follows:
_ENTRIES |
Legacy storage-backend vars present | Behaviour |
|---|---|---|
| Unset / empty | Absent | storage_entries = []. Boot uses framework defaults (Local at storage/). |
| Unset / empty | Present | Synthesize a single entry named default from the legacy vars. Preserves upgrade path — existing deployments keep working without touching .env. |
Set (e.g. foo,bar) |
Absent | Parse each named entry from _STORAGE_<NAME>_* vars. Fail-fast if any declared entry is missing its required per-name fields. |
Set (e.g. foo,bar) |
Present | FAIL FAST. Boot aborts with an error listing every legacy var found. Admin must remove them or migrate them into per-entry _STORAGE_<NAME>_* form. |
Why fail-fast on the "both set" case: without it, admin state is
ambiguous — someone edits OXICLOUD_S3_BUCKET expecting it to matter,
but it's silently ignored because _ENTRIES won. The subtle-bug cost
is much higher than the one-time cleanup cost. Refusing to boot forces
the conversion once, with a clear message naming the exact vars to
remove.
Set of "legacy storage-backend vars" counted for the conflict check:
OXICLOUD_STORAGE_BACKEND, all seven OXICLOUD_S3_*, all five
OXICLOUD_AZURE_*, OXICLOUD_STORAGE_ENCRYPTION_ENABLED, and
OXICLOUD_STORAGE_ENCRYPTION_KEY. OXICLOUD_STORAGE_PATH is NOT in
this set — it drives multiple non-backend things (chunk dir default,
etc.) and remains a valid ambient path; per-entry _ROOT_DIR falls
back to it for Local entries when unset.
One DB row: active_backend_name
Single setting in admin_settings:
storage.active_backend_name = "local_main"
- Boot logic:
- Parse
AppConfig.storage_entriesfrom env. - Read
active_backend_namefromadmin_settings. - If unset (fresh install): use the FIRST name in
_ENTRIES. Log which one, don't fail. - Look up the entry by name. Build
blob_backendfrom it. - If the name doesn't exist in current env (deploy drift — someone removed an entry): fail-fast at boot with a clear error naming the missing entry AND listing the available ones. Admin fixes env or overrides the setting via a fallback CLI (see §Fallback).
- Parse
- Everything the admin panel currently writes about storage
(
s3.bucket,s3.access_key, …) is removed from DB.save_storage_settingsis deleted along with those rows.
Encryption is per-entry
OXICLOUD_STORAGE_<NAME>_ENCRYPTION_KEY (base64 of exactly 32 bytes) on an
entry → that entry's backend is wrapped in EncryptedBlobBackend at build
time. Absent → raw backend.
- Presence-implies-enabled. No separate
_ENCRYPTION_ENABLEDtoggle — one env var per entry is enough. - Fail-fast on invalid key: bad base64, wrong decoded length → boot aborts with the entry name in the error message. A bad key is a real deployment error, must be caught at boot, never silently disabled.
- Legacy
OXICLOUD_STORAGE_ENCRYPTION_KEYremains honoured for the synthesizeddefaultentry when_ENTRIESis empty (upgrade path).
Cross-entry encryption combinations work out of the box because
EncryptedBlobBackend is a decorator and copy_blob in the migration
handler always spools plaintext to a tmp file:
| Source | Target | Migration behaviour |
|---|---|---|
| Raw local | Encrypted S3 (K1) | Read plaintext → write encrypts with K1 |
| Encrypted S3 (K1) | Raw local | Read decrypts with K1 → write plaintext |
| Encrypted S3 (K1) | Encrypted S3 (K2) on different bucket | Read decrypts K1 → write encrypts K2 (rotation via new bucket) |
| Encrypted S3 (K1) | Encrypted S3 (K2) on SAME bucket | REFUSED — see below |
In-place key rotation is refused. Same physical bucket + different
encryption key would have the migration overwrite <hash>.blob with K2
ciphertext while the LIVE backend is still K1-configured → readers get
K1-decrypt-of-K2-bytes → 500. Silent data-loss window. The
is_source_target_identical guard (see §Migration flow) catches this because
storage_identity deliberately excludes the encryption key. The refusal
message spells the case out and recommends the two-step workaround (rotate
via a temp bucket).
Proper in-place rotation (deferred future slice)
The safe implementation is to namespace object keys by encryption
generation — <hash>.k2.blob or k2/<hash>.blob (backend-specific
convention). Then:
- Both key generations coexist in the same bucket during rotation.
- Reads continue against K1 (LIVE backend) — old keys untouched.
- Target writes go to K2 keys.
- Cutover restarts with K2 as live; K1 objects can be reaped async by a follow-up sweep.
- The
is_source_target_identicalguard'sstorage_identitystring changes to INCLUDE the encryption generation — same-bucket different-generation then correctly registers as a legitimate migration, no longer refused.
Schema changes required:
EncryptedBlobBackendgains ageneration: u32field.- Object keys become
<hash>.k<gen>.blob(or backend-specific equivalent — S3 key naming, Azure blob naming). - Read path tries current-gen first, falls back to prior-gen for the rotation window (bounded by cutover-completion + async-reap duration).
Not built now — real key-rotation demand is rare enough that the two-step workaround via temp bucket is acceptable. File this as a follow-up slice AFTER multi-entry lands; the named-entry infrastructure is a prerequisite (generation-versioned keys only make sense when there's an entry model to hold "which generation is active" as configuration).
Read-only mode reuses the existing AuthZ short-circuit
DrivePolicies.read_only already gates writes at
PgAclEngine::check_inner — short-circuits Create|Update|Delete|Share
Permission checks on any resource in a read-only drive. We extend that clause
with a global check:
// Inside PgAclEngine::check_inner, before per-drive read_only check:
if permission.is_write() && self.migration_readonly.load(Ordering::Relaxed) {
return AclDecision::Denied("server in migration read-only mode");
}
- The flag is a
AtomicBoolonAppState, backed byadmin_settings.storage.migration_readonlyso it survives restart (server crashes mid-migration → boots read-only → admin retriggers → still safe). - Admin operations bypass as they already do — admin can still exit read-only, cancel migration, restart the server.
- Reads are unaffected. Users can still browse and download during migration.
- Boot-time clearing: if boot detects
migration_readonly=trueAND no in-flightbackend_migrationrow (noRunning/Paused) ANDactive_backend_namematches the entry the app booted onto → assume successful cutover completed on prior boot, clear the flag. Otherwise leave it set; admin knows they still need to finish something.
Migration flow — atomic + restart-proof
1. Admin picks target entry from a dropdown → clicks "Migrate to s3_prod"
2. Backend:
- Verify target_name exists in AppConfig.storage_entries
- Verify target_name != active_backend_name (no-op guard; existing
`is_source_target_identical` refactored to compare NAMES not identity
strings — but the identity check still runs as a second-line defence
against the encryption-in-place case)
- Write admin_settings.storage.migration_readonly = true
- Trigger `backend_migration` recoverable job with
params = { source_name: "local_main", target_name: "s3_prod" }
3. Migration runs — target resolved fresh each batch by NAME lookup, so
the run's params carries only the name. No secrets in params. If the
process restarts mid-run, the resume path re-resolves the entry from
env by the persisted name. Env is the source of truth for credentials.
4. On Completed:
- Write admin_settings.storage.active_backend_name = "s3_prod"
- Log "Cutover complete — restart the server to switch to the new backend"
- LEAVE read-only mode on. The app is still running with local_main as
the live backend; if we lifted read-only now, writes would go to
local_main even though the DB pointer says s3_prod. Forces the operator
restart, which resolves the ambiguity.
5. Admin restarts the server:
- Boot reads active_backend_name = "s3_prod" → live backend is now S3
- Boot's read-only-clear rule fires (no in-flight migration + active
matches booted entry) → migration_readonly cleared
- Server writable, on the new backend. Cutover complete.
Handling in-progress restart (server dies while migration is running):
- Boot sweep flips
Running→Pausedon the migration row (existing Part 2 machinery). active_backend_nameunchanged. Boots on old backend.migration_readonlystays true (in-flight migration → boot-time clear rule DOESN'T fire).- Admin retriggers migration → run_or_resume reads params.target_name → resumes from cursor with the same target.
No secret ever reaches the DB. Params holds only the entry names. Credentials stay in env; migration resolves them at each batch by name.
Concurrent-write safety — read-only for the copy window
The full-quiesce trade-off: users can browse/download during migration but
cannot upload, rename, delete, or share. For a multi-hour migration this is
noticeable; the alternative (dual-write decorator) is genuinely a week of
work (runtime backend swapping, failure-mode reconciliation, MigrationBlobBackend
rebuild) and only justified if migration is a routine op. Read-only is the
honest v1 answer — pick a low-traffic window, run the migration, restart.
Read-only is engaged at trigger time and cleared at boot after cutover. Nothing more elaborate. Dual-write is filed as a future upgrade if operator demand appears.
?storage=<name> for consistency audits
Once entries are named, ?storage=<name> becomes the generic "probe any
registered entry" knob on the two tenants that touch a backend:
blobs_consistency?storage=<name>— DB → backend probe.backend_consistency?storage=<name>— backend → DB probe.
Unspecified → falls through to the active backend (today's behaviour, preserved).
Use cases this unlocks:
- Pre-cutover verification:
blobs_consistency?storage=s3_prodafter migration completes — full walk (not a sample) proving target has every blob before the .env flip + restart. Retiresverify_migration— sample-check becomes redundant when full audit is one click away. - Pre-decommission verification:
?storage=local_mainafter cutover — check the old backend still has every blob DB expects beforerm -rfthe local.blobs/. - Ad-hoc audit of any registered entry, backup-restore verification, etc.
Plumbing:
JobRunArgsgainsstorage: Option<String>.TriggerJobQueryon the admin trigger endpoint parses?storage=<name>.BlobsConsistencyCheckandBackendConsistencyCheckconstructors takeArc<StorageSettingsService>(or a smallerEntryResolverport); at run start they useargs.storageto pick the backend, falling back to the injected active-backend Arc.- Unknown entry: fail-fast at HTTP layer (
400before the run ever starts) withknown: [local_main, s3_prod]in the response. Cheaper than burning a run row. - Combined with
?deep=true: legit — "full byte-level integrity audit of a named entry, not the live one". Audit log records both. - Params on the run row records
probed_storage: <name>(or "active") for post-hoc diagnosis.
Fallback for the "boot fails on missing entry" case
If admin renames an entry in .env (or removes one that DB still points
at), boot fails fast with a clear error. Operator has two ways out:
- Fix
.env— add the missing entry back OR update_ENTRIESto include an alternative that DOES exist, plus flipactive_backend_namebefore restart. - CLI repair flag on the
oxicloudbinary itself:Behaviour: parseoxicloud storage select <name>.env, verify<name>exists in_ENTRIES(fail-fast with the available names listed if not), connect to DB, UPDATEadmin_settings.storage.active_backend_name, print confirmation, exit 0. Does NOT continue to boot the server — one-shot repair; admin starts the server normally afterwards.
The bare-flag on the shipped binary is chosen over a separate just
recipe or auxiliary bin because:
- Docker-friendly:
docker exec oxicloud oxicloud storage select foo— no need to install extra tooling in the container. - Systemd-friendly: can be run as a
ExecStartPre=one-shot before the main service unit. - No dep on
justbeing installed (dev-machine tool, not typical prod). - Same binary, same env-parse code path: the repair uses THE SAME
.envparser the server does, so "verified present" means the server will succeed on next boot too. No parser drift possible.
The boot-time error message points at this flag explicitly, with the exact command line filled in.
Interaction with existing surfaces
/admin/storagetab rewritten:- Read-only listing of entries (name, backend type, encryption on/off, is active).
- "Migrate to X" dropdown (choose target entry).
- Migration progress + verify/cancel (existing).
- Read-only banner when
migration_readonlyis on. - The Save form, S3-field editors, .env cutover hint — all deleted (no settings edit here anymore).
test_storage_connectionloses the S3-fields DTO; becomes a round-trip probe against a named entry:POST .../test?storage=<name>.verify_migrationdeleted; the "Verify integrity" button on the storage tab is rewired to triggerblobs_consistency?storage=<name>against the migration target (or dropped in favour of the standard admin/jobs Run button — TBD in slice 6).
Slice breakdown
Single PR is too big; splitting into a coherent sequence. Slices 1-2 are foundational; the rest layer on top independently within reason.
| # | Slice | Depends on | Rough size |
|---|---|---|---|
| 1 | Config parser: OXICLOUD_STORAGE_ENTRIES + per-entry vars + _ENCRYPTION_KEY → Vec<NamedStorageEntry> on AppConfig. Legacy synthesis for empty _ENTRIES (default entry from legacy flat vars). |
— | 1 day |
| 2 | Boot: read active_backend_name from admin_settings; look up entry; build blob_backend via a shared build_entry_backend(&NamedStorageEntry) factory (wraps encryption decorator when key present). Fail-fast on missing entry with actionable message. |
1 | ~half day |
| 3 | Migration handler rewrite: params carry target_name only. Handler resolves target by name from storage_settings.build_entry_backend(name). is_source_target_identical guard refactored to compare NAMES first; keeps physical-identity check as second-line refusal (with encryption-differs-specific message). Retire the migration DTO S3-field body. |
1, 2 | ~half day |
| 4 | Global migration_readonly flag on AppState, backed by admin_settings.storage.migration_readonly. One clause added to PgAclEngine::check_inner. Boot-time clear rule (no in-flight + active matches booted → clear). |
2 | ~half day |
| 5 | Cutover state machine: on migration Completed, write active_backend_name = target_name, keep read-only on. Boot on new backend after operator restart. |
4 | ~half day |
| 6 | Admin storage tab rewrite: list entries, show active, migrate dropdown, read-only banner. Delete Save form + S3 field editors + .env cutover hint. | 1, 3, 4 | 1 day |
| 7 | ?storage=<name> on blobs_consistency + backend_consistency. JobRunArgs.storage plumbing, TriggerJobQuery.storage, entry-resolver at run start, params records probed name. Retire verify_migration + its DTO + its route + its handler. |
1, 3 | 1 day |
| 8 | oxicloud storage select <name> bare-flag repair command on the main binary. Parses .env, verifies entry exists, UPDATEs DB, exits. Boot-time missing-entry error message points at it. See §Fallback. |
2 | ~quarter day |
Total: ~5-6 days end to end. Slices 6 and 7 can proceed in parallel with each other once 1-5 land. Slice 8 is an ops nicety, could ship whenever.
Verification
Per slice, plus these end-to-end scenarios in Hurl:
- Fresh install, no
_ENTRIES: boot uses synthesizeddefaultentry from legacy vars,active_backend_nameunset.GET /admin/settings/storagereturns one entry, active. No cutover UI. - Two entries, no active set: boot picks first in
_ENTRIES, logs it, proceeds. Admin panel shows both entries with the picked one marked active. - Change active without migration (rarely useful but must be safe):
POST /admin/settings/storage/active-name(or however the pointer is exposed) updates the row; next restart boots on the new entry. Existing blobs on the new entry NOT verified — operator's problem, butblobs_consistencycatches the drift on next run. - Migration happy path: two entries, entry A active, migrate to B.
Read-only comes on. Copy runs.
active_backend_nameflips to B on completion. Read-only stays on until restart. After restart, live is B, read-only cleared. - Restart mid-migration: kill server after checkpoint N. Boot: row Paused,
activestill A,migration_readonlystill true. Admin retriggers → resumes from checkpoint N against target B (resolved by name from params). Complete → pointer flips → restart → live is B. - In-place encryption rotation refused: two entries, same S3 bucket, different encryption keys. Trigger migration → refuses with the specific error message pointing at the encryption case and the two-step workaround.
?storage=<name>on backend_consistency: run againsts3_prodbefore cutover. Full walk,probed_storagein run row. Then cutover, then rerun againstlocal_main— verifies old backend still has everything. (This scenario namedblobs_consistencyuntil that tenant became database-only; entry scoping belongs to whichever job opens a backend.)- Unknown storage name:
POST /admin/jobs/backend_consistency/trigger?storage=nope→ 400 with known-names list. No run row created. - Missing entry at boot:
active_backend_name = "gone"but_ENTRIESdoesn't include it → boot aborts with the specific message pointing atoxicloud storage select <name>(with the available names filled in). Re-run the binary withstorage select local_main→ verifies + updates DB + exits 0. Restart the server → boots cleanly onlocal_main. - Encryption key invalid:
OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY=badbase64→ boot aborts with entry name + reason (not valid base64 / wrong length). - Legacy vars alongside
_ENTRIES: set_ENTRIES=fooAND leave a staleOXICLOUD_S3_BUCKET=...in.env. Boot aborts with the full list of legacy vars detected, tells the admin to remove them (or move them intoOXICLOUD_STORAGE_<NAME>_S3_BUCKETform). Removing the legacy var → next boot succeeds. - Legacy synthesis path:
_ENTRIESunset,OXICLOUD_STORAGE_BACKEND=s3OXICLOUD_S3_BUCKET=...set. Boot synthesizes one entry nameddefaultfrom those vars.storage_entries.len() == 1, name isdefault, backend is S3, config carries the flat-var values.
Out of scope
- Dual-write decorator / zero-downtime migration: read-only is v1;
dual-write is filed under "if operator demand appears". Would rebuild
MigrationBlobBackend(deleted in the recoverable-migration PR — retained in git for the same reason). - In-place encryption key rotation (same-bucket, different key): refused by the identity guard; workaround is two-step via temp bucket. Proper fix (per-generation object naming) is documented inline in the Encryption section above; deferred until real demand appears.
- Runtime backend hot-swap without restart: not attempted. Requires
Arc<RwLock<Arc<dyn BlobStorageBackend>>>indirection at every call site plus per-request coordination; huge blast radius. Read-only + restart is the honest answer. - Per-user or per-drive storage backends: everything in this plan is
server-scoped. If per-drive storage becomes a real need, the named-entry
registry is the right substrate but
blob_backendon AppState becomes aEntryResolverand every call site changes. Not now. - DB storage config UI: retired. Admin panel is a controller (list + test + migrate + audit), never a persister. If persisted per-entry knobs become a need (retention days per entry, quota per entry, …), those live in DB rows keyed by entry name — a small extension, not a return to the old model.
- Secret encryption in DB:
admin_settings.storage.*secret rows are deleted along withsave_storage_settings. If the migration params approach ever grows to store secrets (e.g., a future "supply the target creds inline for one-off migrations"), those get encrypted at rest with a KMS-provided key. Not needed for this plan — params holds only names.
Related memory notes
feedback_no_abbreviated_env_vars— full-word env var names (OXICLOUD_STORAGE_local_main_S3_ENDPOINT_URL, notOXICLOUD_STORAGE_local_main_S3_EP).feedback_config_file_overrides_shell—dotenvybehaviour on explicit config path. Matters when_ENTRIESvalues are loaded from--configvs shell.project_admin_middleware_layer— admin ops bypass read-only via existing middleware; the AuthZ short-circuit added in slice 4 doesn't need any new admin carve-out.bug_drive_rename_editor_can_do_it— reminder thatPgAclEngine::check_inneris where write-permission short-circuits live; the new global read-only clause lands next to the per-drive one.docs/plan/job-registry.mdPart 2 — recoverable-run engine thatbackend_migrationruns on;paramsfield, resume semantics, boot sweep.