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:
- What the backup subsystem does today (P1 state, verified against
the current
0.15.0tree). - 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 asBackupJobState(#[schema(as = BackupJobState)]:178) to avoid a component clash withsynvirt_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 /jobs→create_job(:123),POST /jobs/:id/cancel→cancel_job(:147),POST /repositories→create_repository(:164),POST /restore→restore(: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.
Daemon proxy + OpenAPI
Section titled “Daemon proxy + OpenAPI”crates/daemon/src/api/backup_proxy.rs: 3 annotated GET reads + a wildcardany(forward)(:58).POST /api/v1/backup/repositoriesalready forwards to backupd viaforward_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 incrates/daemon/src/main.rs:682fromDEFAULT_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 inpaths(...)(: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.jsoncarries onlyGET /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.vuerenders KPIs, a jobs table, and a Repositories / Restore-points / Activity grid.stores/backup.ts(useBackupStore,defineStore('synvirt-backup')): the only action isrefresh()(:118), whichPromise.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 rendersbuildSampleData()(sampleData.ts:117) behind a “Preview” banner — the sample set already includes anS3-Immutablerepository (sampleData.ts:139). - The “Register repository” affordance already exists but is disabled:
BackupView.vue:326-330— aButtonwithdisabledandtitle="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.
Tests today (10 green, 0 fail)
Section titled “Tests today (10 green, 0 fail)”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.
Dependencies (relevant)
Section titled “Dependencies (relevant)”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_dir — no credential storage.
Part 2 — Feature: “Add an S3 repository”
Section titled “Part 2 — Feature: “Add an S3 repository””What it means (scope)
Section titled “What it means (scope)”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_repositoryroute to persist a realRepository(reusing the already-testedput_repository). - Extend the wire contract (append-only) so a
Repositorycarries 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/repositoriesinto 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
createRepositorystore 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
lockedmarker + 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)”-
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
RepositoryConnectionadded as one new optional field (e.g.connection: Option<RepositoryConnection>,#[serde(default)]), whereRepositoryConnectionis a#[serde(rename_all="snake_case")]enum with anS3 { endpoint, region, bucket, prefix, .. }variant. Extensible to the other kinds, one field added, still append-only. Add the enum as a newToSchemacomponent inopenapi.rs. Either way: the field is additive — old readers/writers must keep deserializing existing records (use#[serde(default)]).
- (A) Flat optional fields on
-
Where secrets live and how they’re protected. S3 needs an access key id + secret access key. They must never appear in
Repository(returned byGET /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 keysynvirt-certsreuses for DNS-01 creds (CLAUDE.md §5.12). This adds asynvirt-backupd → synvirt-bmcdependency; the alternative is addingaes-gcmdirectly and reading the same keyfile. Decide which. - Persist the ciphertext in redb — either a new
repository_secretstable keyed by repo id, or an encrypted blob folded into the repo record. Decide the storage shape.
- Secrets travel only in the create-request DTO (
-
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. -
S3 client dependency (only if verifying now).
aws-sdk-s3(official, heavy, full object-lock support),rust-s3(light), orobject_store(clean async, weaker object-lock surface). Pick per whether object-lock config is needed at registration. None needed for store-only. -
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.lockedand add an object-lock mode (e.g.Option<ObjectLockMode { Compliance, Governance }>on the S3 connection). Enforcement stays in the engine phase — say so. -
Response semantics.
create_repositoryshould return201 Createdwith the persistedRepository(secrets stripped). Confirm the daemon proxy passes the body + status through unchanged (the wildcard/POSTforwarder does; verify no body-size issue —MAX_PROXY_BODYinuds_proxy.rsis 1 MiB, fine for a repo definition).
Concrete change plan (ordered, per file)
Section titled “Concrete change plan (ordered, per file)”synvirt-backup-core/src/types.rs— add the connection type (option B) as an additive#[serde(default)]field onRepository; addRepositoryConnection(+ObjectLockMode) enums, all derivingSerialize/Deserialize/ToSchema. Add aCreateRepositoryRequestDTO carryingname,kind,locked, the connection, and the secret fields (secrets#[serde(skip_serializing)]or simply never returned). Re-export fromlib.rs(:22-25). Respect append-only.synvirt-backup-core/src/error.rs— no new variant needed;InvalidRepository(:39) already exists. (Optionally add aRepositoryUnreachable/Cryptovariant only if verify-on-create or encryption failures need distinct codes — append at the end, newE_BACKUP_*code, never reorder.)synvirt-backupd— new secret-encryption seam (crypto dep orsynvirt-bmc);state.rsgains a secrets table or encrypted-blob path (+ decrypt accessor for the future engine);http.rs:164create_repositorybecomes a real handler: extractJson<CreateRepositoryRequest>, validate →InvalidRepository, buildRepository, encrypt+store creds,put_repository, return201 Createdwith the redacted repo. UpdateCargo.tomldeps.crates/daemon/src/api/backup_proxy.rs— add a#[utoipa::path(post, …, operation_id = "backup_create_repository")]onforward_create_repository(:134) so it enters the contract (uniqueoperation_id, per CLAUDE.md §4.1). Reference the new DTO body type.crates/daemon/src/api/openapi.rs— addcrate::api::backup_proxy::create_repositorytopaths(...)(near:346-348) and register the new DTO + connection/enum components (near:882-892).- OpenAPI snapshot + TS types —
synvirt-daemon --dump-openapi > crates/web-ux-v2/openapi.snapshot.json; thennpm run generate:api:from-fileincrates/web-ux-v2. crates/web-ux-v2/src/stores/backup.ts— add acreateRepository(req)action (typedopenapi-fetchPOST), refresh on success, surfaceApiError.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 makesisSamplefalse and the panel shows real data.crates/web-ux-v2/src/api/ERROR_MAP— confirmE_BACKUP_INVALID_REPOSITORY(and any new code) has curated operator-facing copy.- Tests — core:
CreateRepositoryRequest/RepositoryConnectionround-trip + snake_case wire + secret-not-serialized. backupd: handler 201 happy path, validation → 400E_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 dashboardERROR_MAPand the generated TS client key on them; add-only.- Secrets never cross GET. No access key/secret in
Repository, in logs, or in the errordetails. 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 explicitoperation_id(§4.1). - Re-dump
openapi.snapshot.json+ regenerate TS after touching the contract (frozen-contract rule; commit-confirm — seereference_openapi_contract). - Deploy lockstep:
scripts/deploy-to-host.shalready 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-openapidiff, andnpm run typecheck && npm run lint -- --max-warnings 0 && npm run testincrates/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).