Skip to content

Handoff report — Backup subsystem (SynBaaS) + "Add S3 Repository" option

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/superpowers/specs/backup-s3-repository.md. Edit at the source, not here.

Handoff report — Backup subsystem (SynBaaS) + “Add S3 Repository” option

Section titled “Handoff report — Backup subsystem (SynBaaS) + “Add S3 Repository” option”

Audience: an agent that will turn this into a concrete implementation prompt. This report has two halves:

  1. What the backup subsystem does today (P1 state, verified against the current 0.15.0 tree).
  2. Scope + insertion points + open decisions for adding the “add an S3 repository” option end-to-end (backend + wire + UI).

All file:line references were read directly from the working tree on 2026-07-01 and are exact unless flagged “verify”.


Part 1 — What the backup subsystem does today (P1)

Section titled “Part 1 — What the backup subsystem does today (P1)”

One line: Backup is a fully-wired, honest metadata skeleton with no engine. Every layer exists end to end, but nothing captures a snapshot, talks to a repository, restores, prunes, or schedules. Every engine route returns a truthful 501 E_BACKUP_NOT_IMPLEMENTED.

Architecture (mirrors the R2/Fix70 “split data plane” pattern)

Section titled “Architecture (mirrors the R2/Fix70 “split data plane” pattern)”
Layer Crate / path Role
Contract crates/synvirt-backup-core Pure metadata types + one Error enum. #![deny(unsafe_code)], zero I/O. Everything derives serde + utoipa ToSchema.
Daemon crates/synvirt-backupd Standalone systemd service. Sole owner of redb at /var/lib/synvirt/backupd/state.redb. Private UDS 0o600 at /run/synvirt/backupd.sock. No network surface.
Proxy crates/daemon/src/api/backup_proxy.rs Reverse-proxies /api/v1/backup/* → backupd /v1/backup/* over the UDS. 503 E_BACKUP_UNAVAILABLE if backupd is down, never panics. The daemon never opens redb.
Console crates/web-ux-v2/src/views/backup/ + stores/backup.ts The /backup data-protection console. Reads the 3 GET endpoints; shows “Preview” sample data when they are empty.

Deploy status: shipped in 0.15.0, hot-deployed to .65/.75 at 0.15.0-Fix81+build.1111 (verified live). Fresh-install enable-loop entry at crates/daemon/src/api/install.rs:1605. systemd unit at iso-builder/live-build/config/includes.chroot/etc/systemd/system/synvirt-backupd.service (After= is ordering-only, so a daemon restart never drags backupd down).

Data model (crates/synvirt-backup-core/src/types.rs)

Section titled “Data model (crates/synvirt-backup-core/src/types.rs)”

Wire contract is append-only (module rustdoc types.rs:14-16: never reorder/remove serialized variants — add at end, deprecate in place).

  • IDs (#[serde(transparent)], schema’d as String/uuid): BackupJobId (:29), RepositoryId (:64), RestorePointId (:99).
  • BackupType { Full, Differential, ForeverIncremental, Locked } (:136).
  • RepositoryKind { LocalZfs, S3, PeerHost, LinuxTarget } (:155)#[serde(rename_all = "snake_case")]; S3 (:158) already exists, documented “An object store reached over the S3 API (object-lock capable)”. Discriminant only — carries no connection details.
  • JobState { … } (:179), exposed to OpenAPI as BackupJobState (#[schema(as = BackupJobState)] :178) to avoid a component clash with synvirt_livemotiond::JobState.
  • Schedule { cron, enabled } (:223, stored verbatim, no parsing).
  • RetentionPolicy { daily, weekly, monthly, yearly } (:236, GFS, default 7/4/6/1).
  • Repository { id, name, kind, locked, created_at } (:260) — the struct this feature extends. locked: bool (:269) marks a Locked Repository (immutable/write-once); P1 marker only, no enforcement.
  • BackupJob { … } (:276), RestorePoint { … } (:302).

Persistence (crates/synvirt-backupd/src/state.rs)

Section titled “Persistence (crates/synvirt-backupd/src/state.rs)”

The only module that touches redb. 3 tables — jobs (:25), repositories (:28), restore_points (:31) — TableDefinition<&str, &[u8]> keyed by UUID string, JSON value, per-write txn+commit.

Key fact for this feature: put_repository (:155), get_repository (:178), list_repositories (:198), delete_repository (:222) are all implemented and tested (repository_round_trips :344) — but put_repository is unreachable over HTTP today because the create route is a 501 stub. Metadata-only: “Chunk data, repository payloads … never enter this store” (:10-14).

Control plane (crates/synvirt-backupd/src/http.rs)

Section titled “Control plane (crates/synvirt-backupd/src/http.rs)”

Router (:41) mounts 7 routes under /v1/backup/*:

  • REAL (hit redb): GET /jobs (:119), GET /jobs/:id (:132), DELETE /jobs/:id (:139), GET /repositories (:160), GET /repositories/:id (:170), GET /restore-points (:181).
  • STUB (501 E_BACKUP_NOT_IMPLEMENTED): POST /jobscreate_job (:123), POST /jobs/:id/cancelcancel_job (:147), POST /repositoriescreate_repository (:164), POST /restorerestore (:187).

create_repository (:164) is the exact stub this feature replaces:

async fn create_repository(State(_state): State<Arc<AppState>>) -> ApiResult<Response> {
Err(ApiError(Error::NotImplemented(
"registering a repository is not available in this release".to_string(),
)))
}

Error envelope: { "error": { code, message, trace_id, details } } (:62), status_for() (:77) maps NotFound→404, Invalid→400, InvalidStateTransition→409, NotImplemented→501, PersistFailed/Internal→500.

Errors (crates/synvirt-backup-core/src/error.rs)

Section titled “Errors (crates/synvirt-backup-core/src/error.rs)”

Flat 8-variant enum. code() (:69) → frozen E_BACKUP_* strings (the dashboard ERROR_MAP keys on them — never rename). Relevant to this feature and already present with no producer yet: InvalidRepository(String) (:39) → E_BACKUP_INVALID_REPOSITORY (:74) → 400. So validation errors for a bad S3 repo have a home already.

  • crates/daemon/src/api/backup_proxy.rs: 3 annotated GET reads + a wildcard any(forward) (:58). POST /api/v1/backup/repositories already forwards to backupd via forward_create_repository (:134); the named POST forwarders exist only because axum 0.7 lets a static GET route shadow the wildcard (:42-48). So the transport for creating a repository is already in place — it just reaches a 501 today.
  • AppState.backup: synvirt_ipc::UdsClient (crates/daemon/src/api/health.rs:165), built in crates/daemon/src/main.rs:682 from DEFAULT_BACKUP_SOCKET = "/run/synvirt/backupd.sock" (crates/daemon/src/config/mod.rs:113).
  • OpenAPI (crates/daemon/src/api/openapi.rs): only the 3 GET reads are in paths(...) (:346-348); the P1 metadata components are registered (:882-892); tag description (:1082). Create/restore are intentionally out of the spec until the engine lands.
  • Frozen contract: crates/web-ux-v2/openapi.snapshot.json carries only GET /jobs, /repositories, /restore-points. Re-dump after touching endpoints (see Verification).

Frontend (crates/web-ux-v2/src/views/backup/)

Section titled “Frontend (crates/web-ux-v2/src/views/backup/)”
  • BackupView.vue renders KPIs, a jobs table, and a Repositories / Restore-points / Activity grid.
  • stores/backup.ts (useBackupStore, defineStore('synvirt-backup')): the only action is refresh() (:118), which Promise.all-fetches the 3 GETs. There is no create action. State returned at :142.
  • Sample-vs-real switch: isSample (BackupView.vue:72) = all three lists empty. When true it renders buildSampleData() (sampleData.ts:117) behind a “Preview” banner — the sample set already includes an S3-Immutable repository (sampleData.ts:139).
  • The “Register repository” affordance already exists but is disabled: BackupView.vue:326-330 — a Button with disabled and title="Registering repositories arrives in a later release." (there is also a disabled “New backup job” at :244-254 / :299-310). This feature turns that button live.

3 in synvirt-backup-core (JSON round-trip, snake_case wire, E_BACKUP_ prefix), 7 in synvirt-backupd (4 store, 3 config). Zero HTTP-level tests — the router/handlers/501 envelopes are untested.

crates/synvirt-backupd/Cargo.toml: axum 0.7, redb 2, serde, serde_json, chrono, uuid, toml, tokio, tracing. No S3 client, no crypto dependency. Config (config.rs) has only socket_path, socket_mode, state_dirno credential storage.


Part 2 — Feature: “Add an S3 repository”

Section titled “Part 2 — Feature: “Add an S3 repository””

Let an operator register an S3 object-store repository from the /backup console: pick kind = S3, enter connection details + credentials, persist it, and see it appear in the Repositories panel (which flips the console from Preview to real data). This is the repository registration flow, not the backup-to-S3 data path.

In scope

  • Wire the create_repository route to persist a real Repository (reusing the already-tested put_repository).
  • Extend the wire contract (append-only) so a Repository carries the non-secret S3 connection info; add a create-request DTO that carries the secret material.
  • Store S3 credentials encrypted (never returned by GET).
  • Validate the submission → E_BACKUP_INVALID_REPOSITORY (400).
  • Promote POST /api/v1/backup/repositories into the OpenAPI contract; re-dump the snapshot; regenerate TS types.
  • Frontend: enable the disabled “Register repository” button, add a dialog with a kind selector (start with S3; LocalZfs/PeerHost/ LinuxTarget can be follow-ups) and a createRepository store action.
  • Tests for DTO round-trip, validation, the 201 path, and secret redaction on GET.

Out of scope (later engine phases — call out explicitly in the prompt)

  • Actual backup/restore I/O to S3 (chunk store, upload, manifests).
  • Object-lock enforcement (write-once/retention-lock on PUT). The locked marker + an object-lock mode field are captured here; the engine enforces later.
  • Retention/GC, scheduling execution, job state machine.

Design decisions the prompt must pin down (open questions)

Section titled “Design decisions the prompt must pin down (open questions)”
  1. How to carry the S3 connection in the wire contract (append-only). Repository (types.rs:260) currently has no connection field. Options:

    • (A) Flat optional fields on Repository (s3_endpoint, s3_region, s3_bucket, s3_prefix, …) with #[serde(default)]. Simplest; pollutes the struct with kind-specific fields.
    • (B, recommended) A typed RepositoryConnection added as one new optional field (e.g. connection: Option<RepositoryConnection>, #[serde(default)]), where RepositoryConnection is a #[serde(rename_all="snake_case")] enum with an S3 { endpoint, region, bucket, prefix, .. } variant. Extensible to the other kinds, one field added, still append-only. Add the enum as a new ToSchema component in openapi.rs. Either way: the field is additive — old readers/writers must keep deserializing existing records (use #[serde(default)]).
  2. Where secrets live and how they’re protected. S3 needs an access key id + secret access key. They must never appear in Repository (returned by GET /repositories). Recommended split:

    • Secrets travel only in the create-request DTO (CreateRepositoryRequest).
    • Stored encrypted at rest. Canonical option: reuse synvirt-bmc::encryption::EncryptionKey (AES-256-GCM, keyfile /etc/synvirt/secrets.key) — the same key synvirt-certs reuses for DNS-01 creds (CLAUDE.md §5.12). This adds a synvirt-backupd → synvirt-bmc dependency; the alternative is adding aes-gcm directly and reading the same keyfile. Decide which.
    • Persist the ciphertext in redb — either a new repository_secrets table keyed by repo id, or an encrypted blob folded into the repo record. Decide the storage shape.
  3. Verify-on-create, or store-only? Should registration do a live reachability check (S3 HeadBucket / list) before persisting, or just store and defer all I/O to the engine? A verify step needs an S3 client dependency now. Recommended: store-only for the first slice, with a clearly-marked optional “verify connection” follow-up, so the feature doesn’t pull the whole S3 stack in before the engine exists.

  4. S3 client dependency (only if verifying now). aws-sdk-s3 (official, heavy, full object-lock support), rust-s3 (light), or object_store (clean async, weaker object-lock surface). Pick per whether object-lock config is needed at registration. None needed for store-only.

  5. Object lock / immutability capture. The Backup host role’s core promise is “S3-immutable backups with object lock (compliance or governance mode)” (CLAUDE.md §6). Capture the intent now: keep Repository.locked and add an object-lock mode (e.g. Option<ObjectLockMode { Compliance, Governance }> on the S3 connection). Enforcement stays in the engine phase — say so.

  6. Response semantics. create_repository should return 201 Created with the persisted Repository (secrets stripped). Confirm the daemon proxy passes the body + status through unchanged (the wildcard/POST forwarder does; verify no body-size issue — MAX_PROXY_BODY in uds_proxy.rs is 1 MiB, fine for a repo definition).

  1. synvirt-backup-core/src/types.rs — add the connection type (option B) as an additive #[serde(default)] field on Repository; add RepositoryConnection (+ ObjectLockMode) enums, all deriving Serialize/Deserialize/ToSchema. Add a CreateRepositoryRequest DTO carrying name, kind, locked, the connection, and the secret fields (secrets #[serde(skip_serializing)] or simply never returned). Re-export from lib.rs (:22-25). Respect append-only.
  2. synvirt-backup-core/src/error.rs — no new variant needed; InvalidRepository (:39) already exists. (Optionally add a RepositoryUnreachable/Crypto variant only if verify-on-create or encryption failures need distinct codes — append at the end, new E_BACKUP_* code, never reorder.)
  3. synvirt-backupd — new secret-encryption seam (crypto dep or synvirt-bmc); state.rs gains a secrets table or encrypted-blob path (+ decrypt accessor for the future engine); http.rs:164 create_repository becomes a real handler: extract Json<CreateRepositoryRequest>, validate → InvalidRepository, build Repository, encrypt+store creds, put_repository, return 201 Created with the redacted repo. Update Cargo.toml deps.
  4. crates/daemon/src/api/backup_proxy.rs — add a #[utoipa::path(post, …, operation_id = "backup_create_repository")] on forward_create_repository (:134) so it enters the contract (unique operation_id, per CLAUDE.md §4.1). Reference the new DTO body type.
  5. crates/daemon/src/api/openapi.rs — add crate::api::backup_proxy::create_repository to paths(...) (near :346-348) and register the new DTO + connection/enum components (near :882-892).
  6. OpenAPI snapshot + TS typessynvirt-daemon --dump-openapi > crates/web-ux-v2/openapi.snapshot.json; then npm run generate:api:from-file in crates/web-ux-v2.
  7. crates/web-ux-v2/src/stores/backup.ts — add a createRepository(req) action (typed openapi-fetch POST), refresh on success, surface ApiError.
  8. crates/web-ux-v2/src/views/backup/BackupView.vue — enable the disabled “Register repository” button (:326-330); add a dialog (Headless UI) with a kind selector (S3 first) and S3 fields (endpoint/region/bucket/prefix/access key/secret/immutable + object-lock mode). On submit call the store action; on success the created repo makes isSample false and the panel shows real data.
  9. crates/web-ux-v2/src/api/ERROR_MAP — confirm E_BACKUP_INVALID_REPOSITORY (and any new code) has curated operator-facing copy.
  10. Tests — core: CreateRepositoryRequest/RepositoryConnection round-trip + snake_case wire + secret-not-serialized. backupd: handler 201 happy path, validation → 400 E_BACKUP_INVALID_REPOSITORY, GET never leaks the secret, encrypt/decrypt round-trip. Frontend: vitest for the store action + dialog validation.

Invariants & guardrails (must appear in the prompt)

Section titled “Invariants & guardrails (must appear in the prompt)”
  • Append-only wire contract (types.rs:14-16): only add fields/ variants at the end with #[serde(default)]; never reorder or remove.
  • E_BACKUP_* codes are frozen (error.rs:67) — the dashboard ERROR_MAP and the generated TS client key on them; add-only.
  • Secrets never cross GET. No access key/secret in Repository, in logs, or in the error details. Encrypt at rest.
  • backupd is the exclusive redb owner — all new persistence goes through state.rs; the daemon never opens redb.
  • English only for all strings/UI/commit messages (CLAUDE.md §1); no third-party product names (S3/AWS as a protocol/API is fine — it’s a technical name, like the migrator’s vim_api).
  • Every #[utoipa::path] sets an explicit operation_id (§4.1).
  • Re-dump openapi.snapshot.json + regenerate TS after touching the contract (frozen-contract rule; commit-confirm — see reference_openapi_contract).
  • Deploy lockstep: scripts/deploy-to-host.sh already builds/ships/ enables backupd; a build must ship backupd + daemon + dashboard together (a new proxy contract needs the new backupd behind it).
  • Verify with: cargo test -p synvirt-backup-core -p synvirt-backupd, cargo clippy -p synvirt-backup-core -p synvirt-backupd -- -D warnings, cargo fmt --check, synvirt-daemon --dump-openapi diff, and npm run typecheck && npm run lint -- --max-warnings 0 && npm run test in crates/web-ux-v2.

Why this slice is low-risk and high-leverage

Section titled “Why this slice is low-risk and high-leverage”

The transport (proxy POST forwarder), the store method (put_repository, tested), the error code (E_BACKUP_INVALID_REPOSITORY), and the UI affordance (disabled “Register repository” button) already exist. The work is: extend the contract additively, add a secure secrets seam, turn one 501 into a real handler, wire one store action + one dialog, and re-freeze the OpenAPI contract. It is pure control-plane — no data-plane I/O — so it cannot disturb running VMs, and it is the natural first step that also lights the console up out of Preview mode.


Sources: read directly from the 0.15.0 tree on 2026-07-01 — synvirt-backup-core/src/{types,error,lib}.rs, synvirt-backupd/src/{http,state,config}.rs + Cargo.toml, daemon/src/api/{backup_proxy,openapi}.rs, daemon/src/{config/mod,api/health,main}.rs, web-ux-v2/src/views/backup/* + stores/backup.ts. Cross-checked against CLAUDE.md §5 (backup crate roles), §6 (Backup host role / S3-immutable), §4.1 (backend conventions).