think
16px
820px

Backup System — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. T7 is the controller-driven e2e — do NOT delegate it to a subagent. Task T3 is marked ADVERSARIAL-review — the admin API is the settings/floor/graceful-empty spine; review it harder. Sequencing (important): the backup sidecar reads the backup_settings row from Postgres via psql, so the table must exist first. T1 (migration + settings foundation) is intentionally ordered before T2 (the sidecar) — this is the spec's "sequence T2-before-T1" guidance applied. The sidecar is also written to tolerate a missing/unreadable table (falls back to env → safe defaults, never crashes), so a boot race can't break it.

Goal: A real, restorable disaster-recovery backup of the whole system — the Postgres database + all MinIO document blobs — taken on an admin-configured schedule, retained count-based, and recovered via a documented operator procedure (CORE platform, no license gate). The existing internal/backup context only exports a corpus manifest (a JSON inventory of live documents + content hashes); it tells you what you lost but can't restore it. This adds the missing DR layer around that feature without changing it. Incident-motivated: a docker compose down -v once wiped the demo pgdata + miniodata with no way to restore.

Architecture:
- Backup engine = a compose sidecar, not the app. The obscura container is distroless (no pg_dump). A dedicated obscura-backup sidecar (deploy/Dockerfile.backup = FROM postgres:17 + the mc MinIO client) runs battle-tested pg_dump -Fc + mc mirror. Its entrypoint is a poll loop (~60s): each cycle it reads backup_settings (enabled/interval_hours/retention_count/run_requested_at) via psql, derives the last successful backup from the newest completed set on disk, and runs backup.sh when enabled AND now−last ≥ interval_hours, or when run_requested_at is set (which it clears after running). The DB is the ONLY coupling — no app↔sidecar RPC.
- Opt-in via a backup compose profile (like ldap/keycloak/peruri), so it never surprises the demo. Target = a host-mounted ./backups bind mount; the obscura app mounts the same dir read-only (:ro) for visibility only — it can never mutate or delete a backup.
- A backup set ($BACKUP_DIR/<TS>/, TS=date -u +%Y%m%dT%H%M%SZ) is built in <TS>.partial/ and atomically renamed on full success (a crashed run never looks complete): db.dump (pg_dump -Fc), blobs/ (mc mirror bucket → blobs/), status.json ({ts,result,db_bytes,blob_count,blob_bytes,duration_seconds,error?}), and on success latest.json. Then prune to retention_count (completed sets newest-first, never delete the newest).
- Admin-configurable settings live in a singleton backup_settings row (migration 00087), edited via GET/PUT /api/v1/admin/backups/settings (perm backup.admin) behind anti-footgun floors (interval_hours ≥ 1, retention_count ≥ 1 — mirrors the rate-limit floor), and POST /api/v1/admin/backups/run (sets run_requested_at, returns 202). The app writes the row; the sidecar reads it.
- App-side read-only visibility. A new filesystem catalog (internal/backup/adapters/FSCatalog) reads the mounted $BACKUP_DIR (metadata only — it never serves db.dump/blob bytes): GET /api/v1/admin/backups/status{enabled, backup_dir, retention_count, last_run, sets[]}, gracefully enabled:false when the dir is absent/empty. A new Admin → Backups tab surfaces the editable settings + the read-only set history + a "Run backup now" button; restore is documented, never a UI control.
- Restore = scripts/restore.sh <TS> + docs/BACKUP.md runbook, never an in-app button. It prints what it overwrites, requires explicit confirmation (CONFIRM=yes / interactive y/N), stops obscura, pg_restore --clean --if-exists, mc mirror --overwrite --remove, restarts obscura, verifies /readyz + a doc count.
- Observability tie-in (the exact blind spot behind the incident): a scrape-time gauge obscura_backup_last_success_timestamp_seconds (read from latest.json; 0 when none) on the existing private Prometheus registry, reusing the Phase-1 collector pattern.

Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations) — no new Go deps (filesystem os/encoding/json + a psql-in-sidecar reader; no Go code talks to the sidecar). React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client — no new npm deps. The sidecar is a postgres:17 image + the mc binary (downloaded at build time) + three shell scripts. Tested by curl/docker against the deployed stack, demo-safe.

Spec: docs/superpowers/specs/2026-07-06-backup-system-design.md


Global Constraints (every task)

  • NEVER go test — the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify is cd go && go build ./... && go vet ./... only.
  • Web verify: cd web && npx tsc --noEmit && npx vite build.
  • npm run gen:api after ANY api/openapi.yaml edit (regenerates web/src/api/schema.ts). Run it from web/. Commit the regenerated schema.ts with the task.
  • NO new npm dependencies (npm install is broken: npm11/node25 arborist crash). NO new Go dependencies expected (filesystem + psql-in-sidecar only). If any Go dep proves essential: go get <pkg>@<ver> && go mod tidy from go/, commit go/go.mod + go/go.sum.
  • Deploy ONLY from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] (dev-login director@obscura.local, host port 38080).
  • The backup profile is OPT-IN (profiles: ["backup"]). The default stack (up -d) never starts obscura-backup; the demo is byte-for-byte untouched until an operator runs docker compose --profile backup up -d obscura-backup.
  • backup_settings floors prevent runaway/no-retention: interval_hours ≥ 1 (never 0 → a runaway loop that fills the disk) and retention_count ≥ 1 (never prune to nothing). A malformed/out-of-range stored row falls back to seeded defaults (never bricks the Admin view or the sidecar).
  • Commit per task on main, do NOT push. NEVER git add -A (go/obscura-server is a tracked ELF binary; there is also an untracked scripts/.migrate-seeddms-state.json.pre-incident-bak — do not commit it) — always git add explicit paths.
  • i18n en/id parity (tsc-enforced): feature-co-located web/src/features/admin/i18n.ts; nested groups; en and id identical in shape. Smart-quote gotcha: the Edit tool can mangle /“”; use ASCII quotes, and after editing an i18n file verify npx tsc --noEmit — if quotes broke, rewrite the whole file with Write.
  • down -v is FORBIDDEN (a prior incident wiped the demo pgdata + miniodata). Never docker compose down -v. Single-service teardown uses docker compose stop <svc> / start <svc> (NOT rm); throwaways via docker rm -f. The restore e2e (T7) restores into a THROWAWAY scratch DB (obscura_restore_test) + a temp bucket — NEVER the live DB/bucket.
  • No outer-mux gotcha here. All new routes are /api/v1/admin/backups/* inside the chi router (the existing manifest routes already live there, server.go:719-722). The sidecar↔app coupling is ONLY the backup_settings DB row — no RPC, no new top-level path prefix.
  • e2e restores all state + cleans throwaways — reset backup_settings to seeded defaults, drop the scratch DB + temp bucket, delete the test backup sets, leave the backup profile down. Re-assert the demo (5 modules).

File Map

File Task Role
go/migrations/00087_backup_settings.sql (new) T1 singleton backup_settings (id=1 CHECK, seeded)
go/internal/backup/domain/settings.go (new) T1 Settings + floors + Defaults/Validate + SetStatus/CatalogView value types
go/internal/backup/adapters/settings_pg.go (new) T1 SettingsStore (Load/Save/RequestRun; Load falls back to Defaults)
go/internal/backup/adapters/fscatalog.go (new) T1 FSCatalog reading $BACKUP_DIR (newest-first sets, tolerant parse) + LatestSuccessUnix
go/internal/platform/config/config.go T1 BackupDir string env:"BACKUP_DIR" envDefault:"/backups" on Config
deploy/Dockerfile.backup (new) T2 FROM postgres:17 + mc + the three scripts
scripts/backup.sh (new) T2 one backup set (partial→atomic-rename, status/latest json, prune)
scripts/entrypoint.sh (new) T2 poll loop (read settings via psql, run when due / on-demand, clear flag)
deploy/docker-compose.yml T2 obscura-backup service (profile backup) + ./backups:/backups:ro on obscura + BACKUP_DIR env
.gitignore T2 ignore deploy/backups/ (backup artifacts)
go/internal/httpapi/handlers_backup.go T3 GetBackupSettings/PutBackupSettings/GetBackupStatus/RunBackupNow + view/body structs
go/internal/httpapi/server.go T3 BackupSettingsStore/BackupCatalog interfaces + Deps/Server fields + 4 routes
go/cmd/obscura-server/wire.go T3, T4 build SettingsStore/FSCatalog, inject into Deps (T3); register the backup collector (T4)
api/openapi.yaml T3 /admin/backups/settings GET/PUT, /status GET, /run POST + schemas
web/src/api/schema.ts T3 regenerated via gen:api
go/internal/httpapi/metrics_collectors.go T4 BackupCollector (scrape-time obscura_backup_last_success_timestamp_seconds)
scripts/restore.sh (new) T5 operator restore (confirm gate, stop→pg_restore→mc mirror→start→verify)
docs/BACKUP.md (new) T5 operator runbook (enable profile, settings, copy-off-box, restore, troubleshooting)
web/src/features/admin/data.ts T6 useBackupSettings/useSaveBackupSettings/useBackupStatus/useRunBackup
web/src/features/admin/BackupsTab.tsx (new) T6 settings editor + Run-now + read-only set history
web/src/features/admin/AdminPage.tsx T6 register the Backups tab (Tab + TabPanel + import)
web/src/features/admin/i18n.ts T6 admin.backups.* + admin.tabs.backups (en/id)
web/src/styles/app.css T6 small .backup-* layout block

Scouted anchors (verified this session — cite these while implementing)

  • Existing backup Service surface (do NOT break it). go/internal/backup/app/service.go: NewService(repo, blobs, uow) *Service (:55) with RunBackup(ctx, p) (string, error) (:71), ListBackups (:109), GetBackup (:114), OpenManifest (:124). Port app.Repository (:28-40): CorpusEntries/InsertBackup/ListBackups/GetBackup. domain.Backup (domain/backup.go:15) + BuildManifest (:55). Store adapters.NewStore(*db.DB) *Store (adapters/pg.go:32). The new work ADDS SettingsStore + FSCatalog in the SAME backup/adapters package and settings/catalog value types in backup/domain — the manifest feature is untouched.
  • backup.admin perm already exists and gates the manifest routes in go/internal/httpapi/server.go:719-722 (r.With(s.requirePerm("backup.admin")).Post/Get("/admin/backups"…)). The perm is NOT in a migration/seed file (it is referenced only here); it is already granted to the admin/director role in the running demo (the existing manifest export works). The new routes reuse the SAME perm — no perm-catalogue change needed. Add the 4 new routes in this same block.
  • Deps/Server structs (server.go): Deps (:82-119) — Backup *backupapp.Service (:109), Metrics *Metrics (:114), and the RateLimitStore RateLimitStore interface field (:117, interface defined :73-78) is the template to mirror for BackupSettingsStore. Server struct fields backup (:150), metrics (:155), rateLimitStore (:165); NewServer(d Deps) maps d.X → s.x (:206 maps backup, :225 maps rateLimitStore). Add BackupSettings/BackupCatalog to Deps + Server + the NewServer mapping.
  • Rate-limit settings pattern to MIRROR (observability Phase 3): go/internal/ratelimit/domain/ratelimit.goConfig + per-limit floor consts (FloorUser=60…) + Defaults() (:28) + Validate() (:33, returns a clear per-field floor error). go/internal/ratelimit/adapters/pg.goStore{db} with Load (:23, errors.Is(err, pgx.ErrNoRows)Defaults(); a failed Validate()Defaults()) + Save (:41). Migration 00086_rate_limit_settings.sqlid int PRIMARY KEY DEFAULT 1 CHECK (id = 1), seeded INSERT … (id) VALUES (1). Mirror all three for backup_settings.
  • Rate-limit HTTP handler to MIRROR go/internal/httpapi/handlers_ratelimit.go: rateLimitLimits/rateLimitView body+view structs (:12-25), rateLimitView(cfg) builder (:27), GetRateLimits (:36, writeJSON(w, 200, …)), PutRateLimits (:42) — json.NewDecoder(r.Body).Decode, cfg.Validate()writeProblem(&kernel.Error{Kind: ErrValidation, Code: "ratelimit.floor", …}) on 400, Save then live-apply then writeJSON. Mirror shape for backup (Code: "backup.floor").
  • Migrations run to 00086 (ls go/migrations: …00085_scim_group_roles, 00086_rate_limit_settings). Next free = 00087. Goose style: -- +goose Up / -- +goose Down, a CREATE TABLE + seed INSERT, drop on Down (see 00086).
  • Config env pattern (go/internal/platform/config/config.go): top-level Config struct (:17-56) with env:"…" envDefault:"…" tags (e.g. GotenbergURL … envDefault:"http://localhost:33000" :43); env.ParseAs[Config]() (:406); Validate() (:417) fail-closed switch. BACKUP_DIR is a plain path (no validation needed — an absent path just reports the feature disabled) → add the field, no validate() helper.
  • Metrics collector pattern to MIRROR (observability Phase 1): go/internal/httpapi/metrics_collectors.goDBPoolCollector/AITokensCollector are scrape-time prometheus.Collectors (Describe/Collect; NewDesc + MustNewConstMetric). Metrics.Register(c prometheus.Collector) error (metrics.go) adds to the private registry. wire.go registers them right after metrics := httpapi.NewMetrics() (wire.go:466): metrics.Register(httpapi.NewDBPoolCollector(database.Stat)) (:469), metrics.Register(httpapi.NewAITokensCollector(…)) (:472). Add NewBackupCollector + register it the same way (wire.go:474).
  • wire.go (go/cmd/obscura-server/wire.go): backupSvc := backupapp.NewService(backupadapters.NewStore(database), blobStore, database) (:299); backupadapters imported (:21), backupapp (:22). Deps{…} literal (:476-524) — Backup: backupSvc (:503), Metrics: metrics (:508), RateLimitStore: rateLimitStore (:510), Config: httpapi.Config{…} (:512). cfg is the parsed config.Config (has the new BackupDir). Build backupadapters.NewSettingsStore(database) + backupadapters.NewFSCatalog(cfg.BackupDir) near :299, inject both into Deps, register the collector.
  • OpenAPI shape to MIRROR (api/openapi.yaml): /api/v1/admin/rate-limits GET/PUT (:5460) — tags: [admin], 200 + 400/401/403 $ref '#/components/responses/Problem'; schemas RateLimitLimits/RateLimitView (:7722-7739). Existing manifest paths /api/v1/admin/backups (:6023), /{id} (:6059), /{id}/manifest (:6081). Add /admin/backups/settings, /status, /run + BackupSettings/BackupSettingsView/BackupStatus/BackupSet schemas. In chi, static segments (settings/status/run) match before the {id} param route, so the new statics are NOT swallowed by /admin/backups/{id}.
  • compose (deploy/docker-compose.yml): profile pattern profiles: ["ldap"|"keycloak"|"peruri"] (:206,:226,:246). postgres = pgvector/pgvector:pg17, user/pass/db obscura/obscura/obscura, in-network host postgres:5432 (:6-13, DATABASE_URL at :97). minio = minio/minio:latest, root obscura/obscura-dev-secret, bucket obscura, in-network host minio:9000 (:20-27; obscura passes S3_ENDPOINT: minio:9000/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY/S3_USE_SSL at :103-107). obscura volumes: block (:176-186), ports: ["38080:8080"] (:186). Named volumes: block (:274-278). Build contexts are relative to the compose file dir (deploy/): context: .. = repo root. Add obscura-backup (profile backup, context: .., ./backups:/backups), and - ./backups:/backups:ro + BACKUP_DIR: /backups on obscura.
  • .dockerignore (repo root) excludes deploy and *.md, **/*_test.go (contents shown: .git ui stego *.md deploy data datalog logs **/*_test.go go/bin dist tmp). A repo-root build context (context: ..) therefore cannot COPY anything under deploy/ — but scripts/ is NOT ignored. deploy/Dockerfile proves the pattern: context: .. + dockerfile: deploy/Dockerfile works (the Dockerfile is read out-of-band), and it COPYs only go/ + api/openapi.yaml. Therefore Dockerfile.backup uses context: .. and COPYs scripts/backup.sh, scripts/entrypoint.sh, scripts/restore.sh (all under the non-ignored scripts/).
  • deploy/Dockerfile (multi-stage → gcr.io/distroless/static-debian12:nonroot, USER nonroot) is the style reference; Dockerfile.backup is single-stage FROM postgres:17 (Debian; has bash, psql, pg_dump, pg_restore, createdb, GNU date -d, du -b, stat -c) + ADD the mc binary.
  • AdminPage tab registration (web/src/features/admin/AdminPage.tsx): tab labels <Tab>{t('admin.tabs.X')}</Tab> (:108-119, last is observability :119); panels in <TabPanels> (:122-247, last is <TabPanel><ObservabilityTab/></TabPanel> :244-246); components imported (:38-48, ObservabilityTab :48). The order of <Tab>s MUST match the order of <TabPanel>s — register Backups as the LAST tab AND the LAST panel.
  • Settings-editor tab to MIRROR (web/src/features/admin/ObservabilityTab.tsx): useTranslation, NumberInput/Button/InlineNotification from @carbon/react, a draft state seeded from data via useEffect, a Save that surfaces the backend floor error by JSON-parsing (e as Error).message for .detail, and a read-only strip. data hooks (web/src/features/admin/data.ts:748-785): useRateLimits/useSaveRateLimits (api.GET/PUT('/api/v1/admin/…').then(ok), refetchInterval, invalidateQueries), typed via components['schemas']['…']. i18n (web/src/features/admin/i18n.ts): export const en = {…} then export const id: typeof en = {…}; admin.tabs.* group (en :12-25, observability:'Observability' :24; id :551-564, observability:'Observabilitas' :563); observability:{…} copy group (en :26-41; id :565). ASCII quotes only in NEW keys.

Task 1: Migration 00087 + backup settings domain + adapters (SettingsStore + FSCatalog) + config env

Files: go/migrations/00087_backup_settings.sql (new), go/internal/backup/domain/settings.go (new), go/internal/backup/adapters/settings_pg.go (new), go/internal/backup/adapters/fscatalog.go (new), go/internal/platform/config/config.go.

Interface produced (consumed by T2 sidecar reads, T3 handlers, T4 collector): the backup_settings table; backupdomain.Settings/SetStatus/CatalogView + floors + Defaults/Validate; backupadapters.NewSettingsStore(*db.DB) *SettingsStore (Load/Save/RequestRun); backupadapters.NewFSCatalog(dir) *FSCatalog (Read()/LatestSuccessUnix()); cfg.BackupDir.

  • [ ] Step 1 — Migration 00087. Create go/migrations/00087_backup_settings.sql:
-- +goose Up
-- Admin-tunable backup schedule/retention (singleton row id=1). Read by the obscura-backup sidecar
-- each poll (via psql) AND by the app's Admin -> Backups view. Seeded with safe defaults so a fresh
-- deploy has a sane schedule the moment the `backup` profile is brought up; until then the sidecar
-- isn't running, so enabled=true is a no-op. run_requested_at is the "Run backup now" flag: the app
-- sets it, the sidecar runs one backup on its next poll and clears it. Anti-footgun floors
-- (interval_hours>=1, retention_count>=1) are enforced in app validation (a runaway 0-hour schedule
-- would fill the disk; a 0 retention would prune every backup away).
CREATE TABLE backup_settings (
    id               int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
    enabled          bool NOT NULL DEFAULT true,
    interval_hours   int  NOT NULL DEFAULT 24,
    retention_count  int  NOT NULL DEFAULT 7,
    run_requested_at timestamptz,
    updated_at       timestamptz NOT NULL DEFAULT now()
);
INSERT INTO backup_settings (id) VALUES (1);

-- +goose Down
DROP TABLE backup_settings;
  • [ ] Step 2 — Settings + catalog domain. Create go/internal/backup/domain/settings.go (same package as backup.go; stdlib only):
package domain

import "fmt"

// Settings is the admin-tunable backup schedule/retention (the singleton backup_settings row). The
// obscura-backup sidecar reads these via psql each poll cycle; the app reads/writes them for the
// Admin -> Backups view. run_requested_at is NOT modeled here (it is a write-only flag owned by the
// SettingsStore.RequestRun trigger + the sidecar's clear).
type Settings struct {
    Enabled        bool
    IntervalHours  int
    RetentionCount int
}

// Anti-footgun floors (mirrors the rate-limit floor pattern): interval >= 1 (never a runaway
// 0-hour loop that fills the disk), retention >= 1 (never prune to nothing).
const (
    FloorIntervalHours  = 1
    FloorRetentionCount = 1
)

// Defaults returns the seeded settings (matches migration 00087): enabled, daily, keep 7.
func Defaults() Settings {
    return Settings{Enabled: true, IntervalHours: 24, RetentionCount: 7}
}

// Validate enforces the safety floors. A rejected save returns a clear, admin-facing error.
func (s Settings) Validate() error {
    if s.IntervalHours < FloorIntervalHours {
        return fmt.Errorf("interval_hours must be at least %d (safety floor to prevent runaway backups)", FloorIntervalHours)
    }
    if s.RetentionCount < FloorRetentionCount {
        return fmt.Errorf("retention_count must be at least %d (safety floor to keep at least one backup)", FloorRetentionCount)
    }
    return nil
}

// SetStatus is one backup set's status.json (metadata only — never the db.dump/blob bytes).
// Written by the sidecar's backup.sh; parsed best-effort by the FSCatalog. The JSON tags match the
// sidecar's on-disk shape AND the API response.
type SetStatus struct {
    TS              string `json:"ts"`
    Result          string `json:"result"` // "ok" | "error"
    DBBytes         int64  `json:"db_bytes"`
    BlobBytes       int64  `json:"blob_bytes"`
    BlobCount       int64  `json:"blob_count"`
    DurationSeconds int64  `json:"duration_seconds"`
    Error           string `json:"error,omitempty"`
}

// CatalogView is the app-side, read-only view of the mounted backup dir: the newest-first set
// history + the latest-run summary. Enabled is filesystem-derived (dir present AND >= 1 completed
// set), distinct from Settings.Enabled (the auto-backup toggle). Sets is always non-nil so the API
// serializes [] not null.
type CatalogView struct {
    Enabled   bool        `json:"enabled"`
    BackupDir string      `json:"backup_dir"`
    LastRun   *SetStatus  `json:"last_run"`
    Sets      []SetStatus `json:"sets"`
}
  • [ ] Step 3 — SettingsStore. Create go/internal/backup/adapters/settings_pg.go:
package adapters

import (
    "context"
    "errors"
    "fmt"

    "github.com/jackc/pgx/v5"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)

// SettingsStore reads/writes the singleton backup_settings row (id=1). The obscura-backup sidecar
// reads the SAME row directly via psql — the app never RPCs the sidecar; the DB row is the only
// coupling. Mirrors the ratelimit adapters.Store fallback discipline.
type SettingsStore struct{ db *db.DB }

// NewSettingsStore builds the backup-settings store.
func NewSettingsStore(d *db.DB) *SettingsStore { return &SettingsStore{db: d} }

// Load reads the singleton settings, falling back to seeded defaults when the row is missing or
// out-of-range (a corrupt row must never brick the Admin view).
func (s *SettingsStore) Load(ctx context.Context) (domain.Settings, error) {
    var st domain.Settings
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT enabled, interval_hours, retention_count FROM backup_settings WHERE id = 1`).
        Scan(&st.Enabled, &st.IntervalHours, &st.RetentionCount)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Defaults(), nil
    }
    if err != nil {
        return domain.Defaults(), fmt.Errorf("backup settings load: %w", err)
    }
    if st.Validate() != nil {
        return domain.Defaults(), nil
    }
    return st, nil
}

// Save writes the singleton settings and bumps updated_at. It does NOT touch run_requested_at (that
// flag is owned by RequestRun + the sidecar's clear).
func (s *SettingsStore) Save(ctx context.Context, st domain.Settings) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE backup_settings
            SET enabled = $1, interval_hours = $2, retention_count = $3, updated_at = now()
          WHERE id = 1`,
        st.Enabled, st.IntervalHours, st.RetentionCount); err != nil {
        return fmt.Errorf("backup settings save: %w", err)
    }
    return nil
}

// RequestRun sets run_requested_at = now() (the "Run backup now" trigger). COALESCE keeps an already
// pending request from being restacked (the earliest pending timestamp wins). The sidecar clears it
// after running one backup on its next poll.
func (s *SettingsStore) RequestRun(ctx context.Context) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE backup_settings SET run_requested_at = COALESCE(run_requested_at, now()) WHERE id = 1`); err != nil {
        return fmt.Errorf("backup settings request run: %w", err)
    }
    return nil
}
  • [ ] Step 4 — FSCatalog. Create go/internal/backup/adapters/fscatalog.go:
package adapters

import (
    "encoding/json"
    "os"
    "path/filepath"
    "sort"
    "strings"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
)

// tsLayout is the sidecar's compact UTC set-name format (date -u +%Y%m%dT%H%M%SZ). Because it is
// zero-padded and big-endian, a plain reverse string sort is chronological newest-first.
const tsLayout = "20060102T150405Z"

// FSCatalog is the app's read-only view of the mounted backup directory ($BACKUP_DIR, bind-mounted
// :ro). It reads metadata ONLY (each set's status.json + latest.json) — it NEVER opens db.dump or
// blob bytes, and never writes. Tolerant by construction: a missing/unreadable dir -> Enabled=false;
// a set with a missing/malformed status.json is skipped; nothing here can 500 the status endpoint.
type FSCatalog struct{ dir string }

// NewFSCatalog builds the catalog over a mounted backup dir (cfg.BackupDir, default /backups).
func NewFSCatalog(dir string) *FSCatalog { return &FSCatalog{dir: dir} }

// Read scans the backup dir. A completed set is a subdir named <TS> (NOT <TS>.partial) containing a
// status.json. Sets are returned newest-first; LastRun comes from latest.json (written on success).
func (c *FSCatalog) Read() domain.CatalogView {
    out := domain.CatalogView{Enabled: false, BackupDir: c.dir, Sets: []domain.SetStatus{}}
    entries, err := os.ReadDir(c.dir)
    if err != nil {
        return out // dir absent/unreadable -> disabled (profile not enabled / nothing mounted)
    }
    names := make([]string, 0, len(entries))
    for _, e := range entries {
        if !e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
            continue
        }
        names = append(names, e.Name())
    }
    sort.Sort(sort.Reverse(sort.StringSlice(names))) // newest-first
    for _, name := range names {
        if st, ok := readStatus(filepath.Join(c.dir, name, "status.json")); ok {
            out.Sets = append(out.Sets, st)
        }
    }
    if len(out.Sets) > 0 {
        out.Enabled = true
    }
    if last, ok := readStatus(filepath.Join(c.dir, "latest.json")); ok {
        out.LastRun = &last
    }
    return out
}

// LatestSuccessUnix returns the unix seconds of the most recent SUCCESSFUL backup (from latest.json),
// or (0,false) when there is none. Feeds the obscura_backup_last_success_timestamp_seconds gauge.
func (c *FSCatalog) LatestSuccessUnix() (float64, bool) {
    last, ok := readStatus(filepath.Join(c.dir, "latest.json"))
    if !ok || last.Result != "ok" || last.TS == "" {
        return 0, false
    }
    t, err := time.Parse(tsLayout, last.TS)
    if err != nil {
        return 0, false
    }
    return float64(t.Unix()), true
}

func readStatus(path string) (domain.SetStatus, bool) {
    b, err := os.ReadFile(path)
    if err != nil {
        return domain.SetStatus{}, false
    }
    var st domain.SetStatus
    if err := json.Unmarshal(b, &st); err != nil {
        return domain.SetStatus{}, false
    }
    return st, true
}
  • [ ] Step 5 — Config BACKUP_DIR. In go/internal/platform/config/config.go, add a field to the top-level Config struct (near GotenbergURL, :43):
    // BackupDir is the mounted read-only backup directory the app inspects for the Admin -> Backups
    // view + the stale-backup metric (the obscura-backup sidecar writes it; the app only reads it).
    // When the path does not exist the feature reports disabled. Default matches the compose mount.
    BackupDir string `env:"BACKUP_DIR" envDefault:"/backups"`

No Validate() change — an absent path is a valid "feature disabled" state, not a boot error.

  • [ ] Step 6 — Verify + commit. cd go && go build ./... && go vet ./... (the new packages compile standalone; nothing wires them yet — fine). Commit:
git add go/migrations/00087_backup_settings.sql go/internal/backup/domain/settings.go go/internal/backup/adapters/settings_pg.go go/internal/backup/adapters/fscatalog.go go/internal/platform/config/config.go
git commit -m "feat(backup): migration 00087 backup_settings + settings domain, SettingsStore, FSCatalog, BACKUP_DIR"

Task 2: The backup sidecar — Dockerfile.backup + backup.sh + entrypoint.sh + compose service + app :ro mount

Files: deploy/Dockerfile.backup (new), scripts/backup.sh (new), scripts/entrypoint.sh (new), deploy/docker-compose.yml, .gitignore.

Depends on T1 (the backup_settings table). The scripts also tolerate a missing/unreadable table so a boot race never crashes the loop.

  • [ ] Step 1 — Dockerfile.backup. Create deploy/Dockerfile.backup:
# syntax=docker/dockerfile:1
# Disaster-recovery backup sidecar. Real pg_dump/pg_restore (version-matched to the
# pgvector/pgvector:pg17 server) + the MinIO client `mc`, plus the backup/entrypoint/restore
# scripts. OFF by default: only runs under the `backup` compose profile, never in the demo stack.
# Built from the REPO ROOT context (context: ..) because the repo-root .dockerignore excludes
# deploy/ — so the scripts live under scripts/ (not ignored) and are COPYed from there.
FROM postgres:17

# MinIO client (static amd64 binary). Air-gapped installs can pre-place /usr/local/bin/mc and
# override MC_URL to a file:// or internal mirror. ADD fetches at build time (connected build host).
ARG MC_URL=https://dl.min.io/client/mc/release/linux-amd64/mc
ADD ${MC_URL} /usr/local/bin/mc
RUN chmod +x /usr/local/bin/mc

COPY scripts/backup.sh /backup.sh
COPY scripts/entrypoint.sh /entrypoint.sh
COPY scripts/restore.sh /restore.sh
RUN chmod +x /backup.sh /entrypoint.sh /restore.sh

# Runs as root (the postgres image's default before its own entrypoint steps down — which we
# replace): needed to write the host-mounted ./backups bind volume regardless of host ownership.
ENTRYPOINT ["/entrypoint.sh"]

(Note: scripts/restore.sh is authored in T5. If you implement strictly task-by-task, either land T5's restore.sh first, or temporarily create a one-line placeholder scripts/restore.sh so the COPY succeeds and replace it in T5. Recommended: author restore.sh now as an empty executable stub #!/bin/bash\nexit 0 and fill it in T5, OR reorder to do T5's restore.sh before this build. The build only needs the file to exist.)

  • [ ] Step 2 — backup.sh. Create scripts/backup.sh (bash; the postgres:17 image is Debian with bash + GNU coreutils):
#!/bin/bash
# One backup set: pg_dump -Fc + mc mirror of the MinIO bucket into $BACKUP_DIR/<TS>/, built in a
# <TS>.partial dir and atomically renamed to <TS>/ ONLY on full success (a crashed run never looks
# complete). Writes status.json (ok|error), overwrites latest.json on success, and prunes completed
# sets to RETENTION_COUNT (never deleting the newest). Any failure -> status.json result:error in
# the .partial dir, prior good sets untouched, non-zero exit (visible in `docker logs`).
set -uo pipefail

BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_COUNT="${RETENTION_COUNT:-7}"
BUCKET="${S3_BUCKET:-obscura}"

TS="$(date -u +%Y%m%dT%H%M%SZ)"
PARTIAL="$BACKUP_DIR/$TS.partial"
FINAL="$BACKUP_DIR/$TS"
start=$(date -u +%s)
mkdir -p "$PARTIAL/blobs"

fail() {
  local msg="$1" dur
  dur=$(( $(date -u +%s) - start ))
  cat > "$PARTIAL/status.json" <<EOF
{"ts":"$TS","result":"error","db_bytes":0,"blob_count":0,"blob_bytes":0,"duration_seconds":$dur,"error":"$msg"}
EOF
  echo "obscura-backup: FAILED: $msg" >&2
  exit 1
}

# 1) Postgres custom-format dump (compressed, selective restore).
pg_dump -Fc -d "$DATABASE_URL" -f "$PARTIAL/db.dump" || fail "pg_dump failed"
db_bytes=$(stat -c%s "$PARTIAL/db.dump" 2>/dev/null || echo 0)
[ "$db_bytes" -gt 0 ] || fail "db.dump is empty"

# 2) MinIO blob mirror (all document bytes). --remove keeps the fresh partial blobs/ exactly equal
#    to the bucket (harmless on an empty partial dir; guards against a stale re-run leftover).
scheme=http; [ "${S3_USE_SSL:-false}" = "true" ] && scheme=https
mc alias set obscura "$scheme://$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null 2>&1 || fail "mc alias set failed"
mc mirror --overwrite --remove "obscura/$BUCKET" "$PARTIAL/blobs" || fail "mc mirror failed"

blob_count=$(find "$PARTIAL/blobs" -type f | wc -l | tr -d ' ')
blob_bytes=$(du -sb "$PARTIAL/blobs" 2>/dev/null | cut -f1); [ -n "$blob_bytes" ] || blob_bytes=0
dur=$(( $(date -u +%s) - start ))

# 3) status.json (ok) inside the partial, then ATOMIC rename to the final set name.
cat > "$PARTIAL/status.json" <<EOF
{"ts":"$TS","result":"ok","db_bytes":$db_bytes,"blob_count":$blob_count,"blob_bytes":$blob_bytes,"duration_seconds":$dur}
EOF
mv "$PARTIAL" "$FINAL" || fail "rename to final set failed"
cp "$FINAL/status.json" "$BACKUP_DIR/latest.json"
echo "obscura-backup: set $TS ok (db=${db_bytes}B blobs=${blob_count} files/${blob_bytes}B in ${dur}s)"

# 4) Prune to RETENTION_COUNT: completed sets (name matches the TS pattern) newest-first, delete
#    beyond N, never the newest. Count-based is more predictable than age-based.
mapfile -t sets < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort -r)
i=0
for s in "${sets[@]}"; do
  i=$((i+1))
  if [ "$i" -gt "$RETENTION_COUNT" ]; then
    rm -rf "${BACKUP_DIR:?}/$s"
    echo "obscura-backup: pruned old set $s (retention=$RETENTION_COUNT)"
  fi
done
  • [ ] Step 3 — entrypoint.sh (poll loop). Create scripts/entrypoint.sh:
#!/bin/bash
# obscura-backup entrypoint. With ARGS -> exec them (used by restore.sh via
# `docker compose run --rm obscura-backup <cmd>`). With NO args -> the poll loop: every
# BACKUP_POLL_SECONDS (~60s) read backup_settings from Postgres and run backup.sh when a scheduled
# backup is due OR an on-demand run was requested. The DB row is the only coupling to the app.
set -uo pipefail

if [ "$#" -gt 0 ]; then exec "$@"; fi

BACKUP_DIR="${BACKUP_DIR:-/backups}"
POLL_SECONDS="${BACKUP_POLL_SECONDS:-60}"
echo "obscura-backup: poll loop every ${POLL_SECONDS}s; target=${BACKUP_DIR}"

# Read settings, tolerating a missing backup_settings table (before migration 00087 lands) or an
# unreachable DB: on any error psql prints nothing and the caller falls back to env/defaults. The
# loop NEVER crashes on a DB hiccup.
read_settings() {
  psql "$DATABASE_URL" -tAF'|' -c \
    "SELECT enabled, interval_hours, retention_count, COALESCE(EXTRACT(EPOCH FROM run_requested_at)::bigint, 0) FROM backup_settings WHERE id = 1" 2>/dev/null || true
}

# Newest completed set's timestamp as epoch seconds (0 if none), to derive last-success on disk.
latest_success_epoch() {
  local newest y hms
  newest=$(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort -r | head -n1)
  [ -n "$newest" ] || { echo 0; return; }
  y=${newest%%T*}; hms=${newest#*T}; hms=${hms%Z}
  date -u -d "${y:0:4}-${y:4:2}-${y:6:2} ${hms:0:2}:${hms:2:2}:${hms:4:2}" +%s 2>/dev/null || echo 0
}

while true; do
  row="$(read_settings)"
  enabled="$(printf '%s'  "$row" | cut -d'|' -f1)"
  interval="$(printf '%s' "$row" | cut -d'|' -f2)"
  retention="$(printf '%s' "$row" | cut -d'|' -f3)"
  run_req="$(printf '%s'  "$row" | cut -d'|' -f4)"

  # Fallbacks when the row is unreadable: env, then safe defaults.
  [ -n "$enabled" ]   || enabled="${BACKUP_ENABLED:-t}"
  [ -n "$interval" ]  || interval="${BACKUP_INTERVAL_HOURS:-24}"
  [ -n "$retention" ] || retention="${BACKUP_RETENTION_COUNT:-7}"
  [ -n "$run_req" ]   || run_req=0
  # Floors (mirror the app validation; never 0 -> runaway, never prune to nothing).
  { [ "$interval" -ge 1 ]  2>/dev/null; } || interval=24
  { [ "$retention" -ge 1 ] 2>/dev/null; } || retention=7

  now=$(date -u +%s)
  last=$(latest_success_epoch)
  due=0
  if [ "$run_req" -gt 0 ] 2>/dev/null; then
    due=1  # on-demand "Run backup now"
  elif [ "$enabled" = "t" ] || [ "$enabled" = "true" ]; then
    [ $((now - last)) -ge $((interval * 3600)) ] && due=1
  fi

  if [ "$due" -eq 1 ]; then
    RETENTION_COUNT="$retention" /backup.sh || echo "obscura-backup: backup.sh returned non-zero (see logs above)"
    # Clear the on-demand flag after the run (best-effort; a failed run still clears the request so
    # it isn't retried forever — the next scheduled cycle will retry).
    if [ "$run_req" -gt 0 ] 2>/dev/null; then
      psql "$DATABASE_URL" -c "UPDATE backup_settings SET run_requested_at = NULL WHERE id = 1" >/dev/null 2>&1 || true
    fi
  fi
  sleep "$POLL_SECONDS"
done
  • [ ] Step 4 — Compose service + app mount. In deploy/docker-compose.yml:
    1. Add the sidecar service (place it after the web service, before the OIDC-IdP block, ~:197):
  # Disaster-recovery backup sidecar (pg_dump + mc mirror). OFF by default — only runs under the
  # `backup` profile so the demo is untouched:
  #   docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup
  # Reads its schedule/retention/on-off from the backup_settings row in Postgres each poll (~60s);
  # writes sets to the host-mounted ./backups dir (point it at a NAS/external disk in production).
  # Restore is an operator procedure — see scripts/restore.sh + docs/BACKUP.md.
  obscura-backup:
    build:
      context: ..
      dockerfile: deploy/Dockerfile.backup
    profiles: ["backup"]
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_started
    environment:
      DATABASE_URL: "postgres://obscura:obscura@postgres:5432/obscura?sslmode=disable"
      S3_ENDPOINT: "minio:9000"
      S3_BUCKET: obscura
      S3_ACCESS_KEY: obscura
      S3_SECRET_KEY: obscura-dev-secret
      S3_USE_SSL: "false"
      BACKUP_DIR: /backups
      BACKUP_POLL_SECONDS: "60"
    volumes:
      - ./backups:/backups
    restart: unless-stopped
  1. On the obscura service, add the read-only mount to its volumes: block (:176-186, after the peruri_sharefolder line):
      # Read-only view of the backup sets the obscura-backup sidecar writes, for the Admin ->
      # Backups status view + the stale-backup metric. Read-only: the app can never mutate/delete a
      # backup. Absent/empty (profile down) -> the feature reports disabled.
      - ./backups:/backups:ro
  1. On the obscura service environment: block, add (near METRICS_ADDR, :175):
      BACKUP_DIR: /backups
  • [ ] Step 5 — .gitignore. Append to .gitignore (repo root) so backup artifacts + the host mount dir are never committed:
# Backup sets written by the obscura-backup sidecar (host-mounted ./backups).
deploy/backups/
  • [ ] Step 6 — Verify + commit. No Go/web changes. Validate the compose file parses and the scripts are syntactically valid:
docker compose -f deploy/docker-compose.yml config >/dev/null && echo "compose OK"
bash -n scripts/backup.sh && bash -n scripts/entrypoint.sh && echo "scripts OK"

(Do NOT build/run the sidecar here — that happens in T7.) Commit:

git add deploy/Dockerfile.backup scripts/backup.sh scripts/entrypoint.sh deploy/docker-compose.yml .gitignore
git commit -m "feat(backup): obscura-backup sidecar (pg_dump+mc poll loop, profile backup) + app :ro mount"

Task 3 (ADVERSARIAL-review this task — settings/floor/graceful-empty/perm spine): Admin API — settings GET/PUT + status + run

Files: go/internal/httpapi/handlers_backup.go, go/internal/httpapi/server.go, go/cmd/obscura-server/wire.go, api/openapi.yaml, web/src/api/schema.ts.

Contract:
- GET /api/v1/admin/backups/settings (perm backup.admin) → {enabled, interval_hours, retention_count, floors:{interval_hours, retention_count}}.
- PUT /api/v1/admin/backups/settings → validates floors (400 backup.floor below min), live-applies by writing the row (the sidecar picks it up next poll), returns the updated view.
- GET /api/v1/admin/backups/status{enabled, backup_dir, retention_count, last_run, sets[]} from the FSCatalog; gracefully enabled:false (never 500) when the dir is absent/empty.
- POST /api/v1/admin/backups/run → sets run_requested_at, returns 202 {requested:true}.

Adversarial checks to make while reviewing: (a) a below-floor interval_hours=0 OR retention_count=0 PUT returns 400 backup.floor, not a silent clamp; (b) GET /status with NO ./backups mount returns 200 {enabled:false, sets:[]}, not a 500; (c) all four routes are gated by requirePerm("backup.admin") and a non-admin gets 403; (d) the four static routes are NOT shadowed by /admin/backups/{id} (chi statics win); (e) the existing manifest routes (POST/GET /admin/backups, /{id}, /{id}/manifest) still resolve.

  • [ ] Step 1 — Handlers. Append to go/internal/httpapi/handlers_backup.go. First extend the import block to:
import (
    "encoding/json"
    "io"
    "net/http"

    "github.com/go-chi/chi/v5"

    backupdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

Then add:

// backupSettingsBody is the editable schedule/retention (shared by the PUT body + the view).
type backupSettingsBody struct {
    Enabled        bool `json:"enabled"`
    IntervalHours  int  `json:"interval_hours"`
    RetentionCount int  `json:"retention_count"`
}

// backupFloors is the anti-footgun minimums surfaced to the UI.
type backupFloors struct {
    IntervalHours  int `json:"interval_hours"`
    RetentionCount int `json:"retention_count"`
}

// backupSettingsView is GET/PUT /admin/backups/settings: the current settings + the enforced floors.
type backupSettingsView struct {
    backupSettingsBody
    Floors backupFloors `json:"floors"`
}

func backupSettingsToView(s backupdomain.Settings) backupSettingsView {
    return backupSettingsView{
        backupSettingsBody: backupSettingsBody{Enabled: s.Enabled, IntervalHours: s.IntervalHours, RetentionCount: s.RetentionCount},
        Floors:             backupFloors{IntervalHours: backupdomain.FloorIntervalHours, RetentionCount: backupdomain.FloorRetentionCount},
    }
}

// GetBackupSettings returns the current schedule/retention + floors.
func (s *Server) GetBackupSettings(w http.ResponseWriter, r *http.Request) {
    st, err := s.backupSettings.Load(r.Context())
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, backupSettingsToView(st))
}

// PutBackupSettings validates the floors and live-applies by writing the row (the sidecar picks it
// up on its next poll). A below-floor value returns 400 backup.floor.
func (s *Server) PutBackupSettings(w http.ResponseWriter, r *http.Request) {
    var body backupSettingsBody
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "backup.invalid", Message: "invalid request body"})
        return
    }
    st := backupdomain.Settings{Enabled: body.Enabled, IntervalHours: body.IntervalHours, RetentionCount: body.RetentionCount}
    if err := st.Validate(); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "backup.floor", Message: err.Error()})
        return
    }
    if err := s.backupSettings.Save(r.Context(), st); err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, backupSettingsToView(st))
}

// GetBackupStatus returns the read-only catalog of the mounted backup dir (metadata only) plus the
// admin-set retention. Gracefully reports enabled:false when the dir is absent/empty (profile down).
func (s *Server) GetBackupStatus(w http.ResponseWriter, r *http.Request) {
    cat := s.backupCatalog.Read()
    retention := 0
    if st, err := s.backupSettings.Load(r.Context()); err == nil {
        retention = st.RetentionCount
    }
    writeJSON(w, http.StatusOK, map[string]any{
        "enabled":         cat.Enabled,
        "backup_dir":      cat.BackupDir,
        "retention_count": retention,
        "last_run":        cat.LastRun,
        "sets":            cat.Sets,
    })
}

// RunBackupNow sets the run_requested_at flag; the obscura-backup sidecar runs one backup on its next
// poll and clears it. 202 (the app has no direct clock coupling to the sidecar).
func (s *Server) RunBackupNow(w http.ResponseWriter, r *http.Request) {
    if err := s.backupSettings.RequestRun(r.Context()); err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusAccepted, map[string]any{"requested": true})
}
  • [ ] Step 2 — Server wiring (interfaces + Deps + fields + routes). In go/internal/httpapi/server.go:
    1. Add the backupdomain import to the import block:
    backupdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
  1. Just after the RateLimitStore interface (:78), add:
// BackupSettingsStore persists the admin-tunable backup schedule/retention singleton (read live by
// the obscura-backup sidecar via psql). *backupadapters.SettingsStore satisfies it.
type BackupSettingsStore interface {
    Load(ctx context.Context) (backupdomain.Settings, error)
    Save(ctx context.Context, s backupdomain.Settings) error
    RequestRun(ctx context.Context) error
}

// BackupCatalog is the read-only view of the mounted backup dir (metadata only, never blob/db
// bytes). *backupadapters.FSCatalog satisfies it.
type BackupCatalog interface {
    Read() backupdomain.CatalogView
}
  1. In Deps (after Backup *backupapp.Service, :109) add:
    BackupSettings BackupSettingsStore
    BackupCatalog  BackupCatalog
  1. In the Server struct (after backup *backupapp.Service, :150) add:
    backupSettings BackupSettingsStore
    backupCatalog  BackupCatalog
  1. In NewServer (after backup: d.Backup, :206) add:
        backupSettings: d.BackupSettings,
        backupCatalog:  d.BackupCatalog,
  1. In the router, next to the existing manifest routes (after :722), add the four new routes:
            // Admin-configurable backup schedule/retention + read-only set catalog + run-now trigger.
            // Static segments (settings/status/run) match before /admin/backups/{id} in chi.
            r.With(s.requirePerm("backup.admin")).Get("/admin/backups/settings", s.GetBackupSettings)
            r.With(s.requirePerm("backup.admin")).Put("/admin/backups/settings", s.PutBackupSettings)
            r.With(s.requirePerm("backup.admin")).Get("/admin/backups/status", s.GetBackupStatus)
            r.With(s.requirePerm("backup.admin")).Post("/admin/backups/run", s.RunBackupNow)
  • [ ] Step 3 — wire.go injection. In go/cmd/obscura-server/wire.go, right after backupSvc := backupapp.NewService(…) (:299), add:
    backupSettingsStore := backupadapters.NewSettingsStore(database)
    backupCatalog := backupadapters.NewFSCatalog(cfg.BackupDir)

Then in the httpapi.Deps{…} literal (after Backup: backupSvc,, :503) add:

        BackupSettings: backupSettingsStore,
        BackupCatalog:  backupCatalog,

(The T4 collector registration uses backupCatalog too — that step adds the metrics.Register(...) line.)

  • [ ] Step 4 — OpenAPI. In api/openapi.yaml, add the three paths (place them right after the existing /api/v1/admin/backups/{id}/manifest block, ~:6081):
  /api/v1/admin/backups/settings:
    get:
      operationId: getBackupSettings
      summary: Current backup schedule/retention + floors
      description: The admin-tunable auto-backup schedule (enabled, interval, retention) and the safety floors. Requires backup.admin.
      tags: [admin]
      responses:
        '200':
          description: Backup settings view.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BackupSettingsView' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
    put:
      operationId: putBackupSettings
      summary: Update the backup schedule/retention
      description: Set the auto-backup on/off, interval (hours), and retention count (validated against the safety floors; too-low values are rejected). Applied live — the sidecar picks it up on its next poll. Requires backup.admin.
      tags: [admin]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BackupSettings' }
      responses:
        '200':
          description: The stored settings view.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BackupSettingsView' }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /api/v1/admin/backups/status:
    get:
      operationId: getBackupStatus
      summary: Read-only catalog of available backup sets
      description: The mounted backup directory's set history (newest-first, metadata only — never the dump/blob bytes) plus the last-run summary and the configured retention. Reports enabled:false when the backup profile is not running. Requires backup.admin.
      tags: [admin]
      responses:
        '200':
          description: Backup status + set history.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BackupStatus' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
  /api/v1/admin/backups/run:
    post:
      operationId: runBackupNow
      summary: Request an on-demand backup
      description: Sets the run-now flag; the obscura-backup sidecar runs one backup on its next poll and clears it. Idempotent-ish (a pending request isn't restacked). Requires backup.admin.
      tags: [admin]
      responses:
        '202':
          description: Backup requested.
          content:
            application/json:
              schema:
                type: object
                required: [requested]
                properties:
                  requested: { type: boolean }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }

Then add the schemas next to RateLimitView (~:7734, under components: schemas:):

    BackupSettings:
      type: object
      description: The admin-tunable auto-backup schedule/retention.
      required: [enabled, interval_hours, retention_count]
      properties:
        enabled: { type: boolean }
        interval_hours: { type: integer }
        retention_count: { type: integer }
    BackupSettingsView:
      allOf:
        - $ref: '#/components/schemas/BackupSettings'
        - type: object
          required: [floors]
          properties:
            floors:
              type: object
              required: [interval_hours, retention_count]
              properties:
                interval_hours: { type: integer }
                retention_count: { type: integer }
    BackupSet:
      type: object
      description: One completed backup set's status (metadata only).
      required: [ts, result, db_bytes, blob_bytes, blob_count, duration_seconds]
      properties:
        ts: { type: string }
        result: { type: string, enum: [ok, error] }
        db_bytes: { type: integer, format: int64 }
        blob_bytes: { type: integer, format: int64 }
        blob_count: { type: integer, format: int64 }
        duration_seconds: { type: integer, format: int64 }
        error: { type: string }
    BackupStatus:
      type: object
      description: Read-only backup catalog — the set history + last-run + configured retention.
      required: [enabled, backup_dir, retention_count, sets]
      properties:
        enabled: { type: boolean }
        backup_dir: { type: string }
        retention_count: { type: integer }
        last_run:
          allOf: [ { $ref: '#/components/schemas/BackupSet' } ]
          nullable: true
        sets:
          type: array
          items: { $ref: '#/components/schemas/BackupSet' }
  • [ ] Step 5 — Regenerate the client. From web/: npm run gen:api (regenerates web/src/api/schema.ts with the new operations + schemas).

  • [ ] Step 6 — Verify + commit. cd go && go build ./... && go vet ./...; cd web && npx tsc --noEmit && npx vite build. Commit:

git add go/internal/httpapi/handlers_backup.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(backup): admin API — settings GET/PUT (floors) + status catalog + run-now (backup.admin)"

Task 4: Stale-backup metric — obscura_backup_last_success_timestamp_seconds

Files: go/internal/httpapi/metrics_collectors.go, go/cmd/obscura-server/wire.go.

Interface produced: httpapi.NewBackupCollector(func() (float64, bool)) prometheus.Collector, registered on the private registry served at /metrics.

  • [ ] Step 1 — Collector. Append to go/internal/httpapi/metrics_collectors.go (imports time, prometheus are already present in that file):
// ---------------------------------------------------------------------------
// Backup freshness collector — reads latest.json at scrape time (no background goroutine). This is
// the exact blind spot behind the down -v incident: a stale/failed backup becomes visible + alertable.
// ---------------------------------------------------------------------------

var backupLastSuccessDesc = prometheus.NewDesc(
    "obscura_backup_last_success_timestamp_seconds",
    "Unix timestamp of the most recent successful backup (0 when there is none). Read from $BACKUP_DIR/latest.json at scrape time.",
    nil, nil,
)

// BackupCollector exports the last-successful-backup timestamp. lastSuccess is the FSCatalog reader
// (0/false when no successful backup exists). Reading a small JSON file per scrape is cheap and keeps
// the value always current with no background work.
type BackupCollector struct{ lastSuccess func() (float64, bool) }

// NewBackupCollector builds the collector over a last-success reader (pass fsCatalog.LatestSuccessUnix).
func NewBackupCollector(lastSuccess func() (float64, bool)) *BackupCollector {
    return &BackupCollector{lastSuccess: lastSuccess}
}

// Describe implements prometheus.Collector.
func (c *BackupCollector) Describe(ch chan<- *prometheus.Desc) { ch <- backupLastSuccessDesc }

// Collect implements prometheus.Collector.
func (c *BackupCollector) Collect(ch chan<- prometheus.Metric) {
    v, ok := c.lastSuccess()
    if !ok {
        v = 0
    }
    ch <- prometheus.MustNewConstMetric(backupLastSuccessDesc, prometheus.GaugeValue, v)
}
  • [ ] Step 2 — Register in wire.go. In go/cmd/obscura-server/wire.go, next to the other collector registrations (after metrics.Register(httpapi.NewAITokensCollector(…)), :472-474), add:
    if err := metrics.Register(httpapi.NewBackupCollector(backupCatalog.LatestSuccessUnix)); err != nil {
        return fmt.Errorf("register backup collector: %w", err)
    }

(backupCatalog was constructed in T3 Step 3. If implementing T4 before T3's wire edit, construct backupCatalog := backupadapters.NewFSCatalog(cfg.BackupDir) here first.)

  • [ ] Step 3 — Verify + commit. cd go && go build ./... && go vet ./.... Commit:
git add go/internal/httpapi/metrics_collectors.go go/cmd/obscura-server/wire.go
git commit -m "feat(metrics): obscura_backup_last_success_timestamp_seconds gauge (reads latest.json)"

Task 5: scripts/restore.sh + docs/BACKUP.md operator runbook

Files: scripts/restore.sh (new — or replace the T2 stub), docs/BACKUP.md (new).

  • [ ] Step 1 — restore.sh. Create/replace scripts/restore.sh. It runs on the HOST (needs the docker CLI + a running stack) and drives the DB/blob steps through the obscura-backup sidecar container so the operator needs no local pg/mc tooling. Requires the backup profile to be up.
#!/bin/bash
# Restore the whole system (Postgres + MinIO blobs) from a backup set. OPERATOR PROCEDURE — run on
# the host, never from the app. Overwrites the LIVE database + bucket, so it prints exactly what it
# will do and REQUIRES explicit confirmation (CONFIRM=yes env, or an interactive y/N).
#
#   Usage:  ./scripts/restore.sh <TS>            (interactive confirm)
#           CONFIRM=yes ./scripts/restore.sh <TS> (non-interactive)
#
# The `backup` profile must be up (the sidecar runs pg_restore + mc):
#   docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup
set -euo pipefail

COMPOSE="docker compose -f deploy/docker-compose.yml"
TS="${1:-}"
[ -n "$TS" ] || { echo "usage: $0 <TS>  (e.g. 20260706T101112Z)"; exit 2; }

SET_DIR="deploy/backups/$TS"
[ -d "$SET_DIR" ] || { echo "restore: backup set not found: $SET_DIR"; exit 2; }
[ -f "$SET_DIR/db.dump" ] || { echo "restore: $SET_DIR/db.dump missing — set is incomplete"; exit 2; }

echo "This will OVERWRITE the live system from backup set $TS:"
echo "  - Postgres  : pg_restore --clean --if-exists  (drops + recreates every object)"
echo "  - MinIO blobs: mc mirror --overwrite --remove  (bucket becomes an EXACT copy — extras deleted)"
echo "  - obscura will be STOPPED during the restore and STARTED again after."
echo
if [ "${CONFIRM:-}" != "yes" ]; then
  read -r -p "Type 'y' to proceed: " ans
  [ "$ans" = "y" ] || { echo "aborted."; exit 1; }
fi

# In-container paths (the sidecar mounts ./backups at /backups).
DUMP="/backups/$TS/db.dump"
BLOBS="/backups/$TS/blobs"

echo "restore: stopping obscura (no writes during restore)..."
$COMPOSE stop obscura

echo "restore: pg_restore into the live database..."
$COMPOSE exec -T obscura-backup \
  pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL_OVERRIDE" "$DUMP" \
  || $COMPOSE exec -T obscura-backup bash -lc \
     'pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" "'"$DUMP"'"'

echo "restore: mc mirror blobs -> bucket (exact state)..."
$COMPOSE exec -T obscura-backup bash -lc '
  scheme=http; [ "${S3_USE_SSL:-false}" = "true" ] && scheme=https
  mc alias set obscura "$scheme://$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null
  mc mirror --overwrite --remove "'"$BLOBS"'" "obscura/${S3_BUCKET:-obscura}"
'

echo "restore: starting obscura..."
$COMPOSE start obscura

echo "restore: waiting for /readyz..."
for i in $(seq 1 30); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:38080/readyz || true)
  [ "$code" = "200" ] && { echo "restore: /readyz 200"; break; }
  sleep 2
done

echo "restore: document sanity check —"
$COMPOSE exec -T obscura-backup bash -lc \
  'psql "$DATABASE_URL" -tAc "SELECT count(*) FROM documents"' | sed 's/^/  documents=/'

echo "restore: done. Verify the app manually before resuming normal operations."

(The $DATABASE_URL_OVERRIDE || … fallback lets the operator export a custom URL; the default path uses the sidecar's own DATABASE_URL env. Keep whichever branch you prefer — the intent is "pg_restore into the live DB via the sidecar".)

chmod +x scripts/restore.sh
  • [ ] Step 2 — docs/BACKUP.md. Create docs/BACKUP.md — the operator runbook. Cover, in this order: (1) What it backs up (Postgres via pg_dump -Fc + all MinIO blobs via mc mirror; NOT mailpit/keycloak/ldap). (2) Enable the profile (docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup; note it's off by default, demo untouched). (3) Admin settings (Admin → Backups: on/off, interval hours w/ floor 1, retention count w/ floor 1, Run backup now; the sidecar reads backup_settings each ~60s poll so changes apply within a minute). (4) Where sets live (deploy/backups/<TS>/ = db.dump + blobs/ + status.json; latest.json; <TS>.partial/ = a crashed/in-progress run, ignore it). (5) Copy off-box (the host ./backups can be a mounted NAS/external disk; rsync deploy/backups/ user@nas:/obscura-backups/). (6) Restore procedure — the full scripts/restore.sh <TS> walkthrough incl. the confirmation gate, that obscura is stopped during restore, and the --clean/--overwrite --remove overwrite semantics. (7) Verify a restore (/readyz 200 + document count + spot-check a document opens). (8) Troubleshooting table: disk full (prune retention / mount a bigger disk), MinIO creds wrong (mc alias set failed in logs → check S3_*), Postgres version mismatch (sidecar is postgres:17, matched to the pgvector/pgvector:pg17 server), a .partial set that never completed (safe to delete; check docker logs obscura-backup for the error), stale/failed backup surfaced by obscura_backup_last_success_timestamp_seconds in /metrics. (9) A one-line NEVER docker compose down -v warning (it wiped the demo once).

  • [ ] Step 3 — Verify + upload + commit. bash -n scripts/restore.sh && echo ok. Upload the runbook per CLAUDE.md and record the URL in the report:

curl -F "file=@docs/BACKUP.md" https://x056.think.val.id/upload

Commit:

git add scripts/restore.sh docs/BACKUP.md
git commit -m "docs(backup): restore.sh operator script (confirm gate) + BACKUP.md runbook"

Task 6: Web Admin → Backups tab

Files: web/src/features/admin/data.ts, web/src/features/admin/BackupsTab.tsx (new), web/src/features/admin/AdminPage.tsx, web/src/features/admin/i18n.ts, web/src/styles/app.css.

  • [ ] Step 1 — data hooks. Append to web/src/features/admin/data.ts (near the observability hooks, :744):
// --- Admin -> Backups (DR backup schedule/retention + read-only set catalog), gated on backup.admin.
type BackupSettings = components['schemas']['BackupSettings']

export function useBackupSettings() {
  return useQuery({
    queryKey: ['admin', 'backup-settings'],
    queryFn: () => api.GET('/api/v1/admin/backups/settings', {}).then(ok),
  })
}

export function useSaveBackupSettings() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (body: BackupSettings) => api.PUT('/api/v1/admin/backups/settings', { body }).then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'backup-settings'] }),
  })
}

export function useBackupStatus() {
  return useQuery({
    queryKey: ['admin', 'backup-status'],
    queryFn: () => api.GET('/api/v1/admin/backups/status', {}).then(ok),
    refetchInterval: 15000, // reflect new sets + the sidecar picking up a run-now
  })
}

export function useRunBackup() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: () => api.POST('/api/v1/admin/backups/run', {}).then(ok),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'backup-status'] }),
  })
}
  • [ ] Step 2 — BackupsTab. Create web/src/features/admin/BackupsTab.tsx. Settings area = enabled toggle + an interval control (a Daily/Weekly/Hourly Select mapping to 24/168/1, with a NumberInput fallback floored at 1) + retention NumberInput floored at 1 + Save (PUT, surfaces the backend backup.floor message) + a "Run backup now" button (POST run). Available-backups area = a read-only DataTable/plain table of sets newest-first (ts / result / db_bytes / blob_count / blob_bytes) + last-run status + the note.
// Admin -> Backups: disaster-recovery backup schedule/retention + the read-only set catalog. Gated
// server-side on backup.admin. Restore is an OPERATOR procedure (scripts/restore.sh + docs/BACKUP.md)
// — there is intentionally no restore control here.
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Toggle, NumberInput, Select, SelectItem, InlineNotification } from '@carbon/react'
import { StatusTag } from '@/components/StatusTag'
import { useBackupSettings, useSaveBackupSettings, useBackupStatus, useRunBackup } from './data'

type Settings = { enabled: boolean; interval_hours: number; retention_count: number }

// Byte formatting for the set table (KB/MB/GB), ASCII only.
function fmtBytes(n: number): string {
  if (!n) return '0 B'
  const u = ['B', 'KB', 'MB', 'GB', 'TB']
  let i = 0
  let v = n
  while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
  return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}

export function BackupsTab() {
  const { t } = useTranslation()
  const { data, isError } = useBackupSettings()
  const save = useSaveBackupSettings()
  const status = useBackupStatus()
  const run = useRunBackup()
  const [draft, setDraft] = useState<Settings | null>(null)
  const [err, setErr] = useState<string | null>(null)

  useEffect(() => {
    if (data) setDraft({ enabled: data.enabled, interval_hours: data.interval_hours, retention_count: data.retention_count })
  }, [data])

  const floors = data?.floors ?? { interval_hours: 1, retention_count: 1 }
  const cat = status.data
  const sets = cat?.sets ?? []

  const onSave = () => {
    if (!draft) return
    setErr(null)
    save.mutate(draft, {
      onError: (e) => {
        const raw = (e as Error).message
        try {
          const p = JSON.parse(raw) as { detail?: string; title?: string }
          setErr(p.detail || p.title || raw)
        } catch { setErr(raw) }
      },
    })
  }

  // Interval presets (Daily/Weekly/Hourly) map to hours; any other stored value shows as "custom".
  const preset = draft?.interval_hours === 1 ? '1' : draft?.interval_hours === 168 ? '168' : draft?.interval_hours === 24 ? '24' : 'custom'

  return (
    <div className="admin-split__main">
      <p className="page__lead muted">{t('admin.backups.lead')}</p>
      {isError && <p className="muted">{t('admin.loadError')}</p>}

      {/* Settings (editable). */}
      <h3 className="backup-heading">{t('admin.backups.settings.title')}</h3>
      {err && <InlineNotification kind="error" lowContrast title={err} onCloseButtonClick={() => setErr(null)} />}
      {draft && (
        <div className="backup-settings">
          <Toggle
            id="backup-enabled"
            labelText={t('admin.backups.settings.enabled')}
            toggled={draft.enabled}
            onToggle={(v) => setDraft({ ...draft, enabled: v })}
          />
          <Select
            id="backup-interval-preset"
            labelText={t('admin.backups.settings.interval')}
            value={preset}
            onChange={(e) => {
              const v = e.target.value
              if (v !== 'custom') setDraft({ ...draft, interval_hours: Number(v) })
            }}
          >
            <SelectItem value="1" text={t('admin.backups.settings.hourly')} />
            <SelectItem value="24" text={t('admin.backups.settings.daily')} />
            <SelectItem value="168" text={t('admin.backups.settings.weekly')} />
            <SelectItem value="custom" text={t('admin.backups.settings.custom')} />
          </Select>
          <NumberInput
            id="backup-interval-hours"
            label={t('admin.backups.settings.intervalHours')}
            helperText={t('admin.backups.settings.floor', { n: floors.interval_hours })}
            min={floors.interval_hours}
            value={draft.interval_hours}
            onChange={(_e, s) => setDraft({ ...draft, interval_hours: Number((s as { value: number | string }).value) || 0 })}
          />
          <NumberInput
            id="backup-retention"
            label={t('admin.backups.settings.retention')}
            helperText={t('admin.backups.settings.floor', { n: floors.retention_count })}
            min={floors.retention_count}
            value={draft.retention_count}
            onChange={(_e, s) => setDraft({ ...draft, retention_count: Number((s as { value: number | string }).value) || 0 })}
          />
        </div>
      )}
      <div className="backup-actions">
        <Button onClick={onSave} disabled={!draft || save.isPending}>{t('admin.backups.settings.save')}</Button>
        <Button kind="tertiary" onClick={() => run.mutate()} disabled={run.isPending}>{t('admin.backups.settings.runNow')}</Button>
        {save.isSuccess && !err && <span className="muted">{t('admin.backups.settings.saved')}</span>}
        {run.isSuccess && <span className="muted">{t('admin.backups.settings.runRequested')}</span>}
      </div>

      {/* Available backups (read-only). */}
      <h3 className="backup-heading">{t('admin.backups.sets.title')}</h3>
      {cat && !cat.enabled && <p className="muted">{t('admin.backups.sets.disabled')}</p>}
      {cat?.enabled && (
        <table className="backup-sets">
          <thead>
            <tr>
              <th>{t('admin.backups.sets.ts')}</th>
              <th>{t('admin.backups.sets.result')}</th>
              <th>{t('admin.backups.sets.db')}</th>
              <th>{t('admin.backups.sets.blobs')}</th>
              <th>{t('admin.backups.sets.blobBytes')}</th>
            </tr>
          </thead>
          <tbody>
            {sets.map((s) => (
              <tr key={s.ts}>
                <td className="mono">{s.ts}</td>
                <td><StatusTag tone={s.result === 'ok' ? 'success' : 'danger'} label={s.result} /></td>
                <td className="mono">{fmtBytes(s.db_bytes)}</td>
                <td className="mono">{s.blob_count}</td>
                <td className="mono">{fmtBytes(s.blob_bytes)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      <p className="muted backup-restore-note">{t('admin.backups.sets.restoreNote')}</p>
    </div>
  )
}

(If Toggle's onToggle signature differs in this Carbon version, mirror how other tabs use it — grep onToggle in web/src/features/admin. If StatusTag tone values differ, mirror ObservabilityTab's usage.)

  • [ ] Step 3 — Register the tab. In web/src/features/admin/AdminPage.tsx: add the import (near :48):
import { BackupsTab } from './BackupsTab'

Add the LAST tab label (after the observability <Tab>, :119):

          <Tab>{t('admin.tabs.backups')}</Tab>

Add the LAST panel (after the observability <TabPanel>, :244-246, before </TabPanels>):

          {/* Backups ----------------------------------------------------- */}
          <TabPanel>
            <BackupsTab />
          </TabPanel>
  • [ ] Step 4 — i18n (en + id). In web/src/features/admin/i18n.ts, add backups: 'Backups' to the en admin.tabs group and backups: 'Cadangan' to the id admin.tabs group. Add an admin.backups copy group to BOTH en and id (identical shape). English:
    backups: {
      lead: 'Scheduled disaster-recovery backups of the database and document blobs. Restore is an operator procedure run from the host - see docs/BACKUP.md.',
      settings: {
        title: 'Schedule and retention',
        enabled: 'Automatic backups',
        interval: 'Frequency',
        hourly: 'Hourly',
        daily: 'Daily',
        weekly: 'Weekly',
        custom: 'Custom (hours)',
        intervalHours: 'Interval (hours)',
        retention: 'Keep last N backups',
        floor: 'Minimum {{n}}',
        save: 'Save settings',
        saved: 'Saved',
        runNow: 'Run backup now',
        runRequested: 'Backup requested - it will run on the next sidecar poll.',
      },
      sets: {
        title: 'Available backups',
        disabled: 'No backups yet. Enable the backup profile on the server (see docs/BACKUP.md); sets will appear here.',
        ts: 'Timestamp (UTC)',
        result: 'Result',
        db: 'Database',
        blobs: 'Blobs',
        blobBytes: 'Blob size',
        restoreNote: 'Restore is an operator procedure - see docs/BACKUP.md. There is no restore button here on purpose.',
      },
    },

Indonesian (same keys; translate the values, ASCII quotes only). After editing, npx tsc --noEmit; if smart quotes crept in and broke the build, rewrite the whole file with Write using ASCII quotes.

  • [ ] Step 5 — CSS. In web/src/styles/app.css, add a small block near the .obs-* block:
.backup-heading { margin-top: 1.5rem; }
.backup-settings { display: flex; flex-direction: column; gap: 1rem; max-width: 28rem; }
.backup-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; }
.backup-sets { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.backup-sets th, .backup-sets td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--cds-border-subtle, #e0e0e0); }
.backup-restore-note { margin-top: 1rem; }
  • [ ] Step 6 — Verify + commit. cd web && npx tsc --noEmit && npx vite build. Commit:
git add web/src/features/admin/data.ts web/src/features/admin/BackupsTab.tsx web/src/features/admin/AdminPage.tsx web/src/features/admin/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Admin -> Backups tab (schedule/retention editor + run-now + read-only set history)"

Task 7 (CONTROLLER-DRIVEN e2e — do NOT delegate to a subagent): deploy + prove backup/restore/retention/metric, demo-safe

Prereqs: T1–T6 committed on main and building. NEVER docker compose down -v. Single-service teardown only. The restore proof uses a THROWAWAY scratch DB + temp bucket — never the live ones.

  • [ ] Step 1 — Deploy + baseline. From repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Dev-login director@obscura.local (:38080) → TOKEN; GET /api/v1/me → assert enabled_modules == [ai, correspondence, esign, semantic, watermarking]. Assert the backup profile is OFF: docker compose -f deploy/docker-compose.yml ps shows NO obscura-backup.

  • [ ] Step 2 — Settings API + floors. GET /api/v1/admin/backups/settings (Bearer TOKEN) → assert seeded defaults {enabled:true, interval_hours:24, retention_count:7, floors:{interval_hours:1, retention_count:1}}. PUT {enabled:true, interval_hours:1, retention_count:3} → 200 with those values. PUT a below-floor {interval_hours:0}400 with code/detail mentioning backup.floor; same for {retention_count:0}. GET /api/v1/admin/backups/status with the profile still down → 200 {enabled:false, sets:[]} (graceful empty, no 500).

  • [ ] Step 3 — Bring the sidecar up + run one backup. docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup. Run one backup deterministically: docker compose -f deploy/docker-compose.yml exec -T obscura-backup /backup.sh (or POST /api/v1/admin/backups/run and wait ~1-2 poll cycles). Capture the new TS.

  • [ ] Step 4 — Assert set completeness. In the sidecar (docker compose exec -T obscura-backup bash -lc '…'): db.dump is non-empty (stat -c%s /backups/<TS>/db.dump > 0); status.json result == "ok"; blobs/ file count == the live bucket's object count — compare find /backups/<TS>/blobs -type f | wc -l against mc alias set … && mc ls --recursive obscura/obscura | wc -l. Then GET /api/v1/admin/backups/statusenabled:true, the set is listed with matching db_bytes/blob_count, and last_run.ts == <TS>.

  • [ ] Step 5 — Metric present. curl -s :38080/metrics | grep obscura_backup_last_success_timestamp_seconds → a single sample > 0 (the unix time of <TS>).

  • [ ] Step 6 — RESTORE PROOF into THROWAWAY targets (never live). All via the sidecar container:

  • Scratch DB: psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS obscura_restore_test'; createdb -T template0 obscura_restore_test (or psql -c 'CREATE DATABASE obscura_restore_test'); build a scratch URL postgres://obscura:obscura@postgres:5432/obscura_restore_test?sslmode=disable; pg_restore --clean --if-exists --no-owner -d "$SCRATCH_URL" /backups/<TS>/db.dump.
  • Assert row counts MATCH live: for documents, folders, users compare SELECT count(*) in obscura_restore_test vs the live obscura DB — must be equal.
  • Temp bucket: mc mb obscura/restore-test-bucket; mc mirror /backups/<TS>/blobs obscura/restore-test-bucket; assert mc ls --recursive obscura/restore-test-bucket | wc -l == the live bucket object count.
  • Cleanup: psql -c 'DROP DATABASE obscura_restore_test'; mc rb --force obscura/restore-test-bucket.

  • [ ] Step 7 — Retention prune. With retention_count set to a small N (e.g. 2 via PUT), create N+1 sets with distinct timestamps (e.g. loop exec … /backup.sh with a 1s sleep, or fabricate older set dirs by copying + renaming to earlier TS names then run one more /backup.sh so prune fires). Assert exactly N completed sets remain (ls /backups | grep -E '^[0-9]{8}T[0-9]{6}Z$' | wc -l == N) and the OLDEST was removed, newest kept.

  • [ ] Step 8 — Restore-script smoke (optional, guarded). Optionally run CONFIRM=yes ./scripts/restore.sh <TS> to prove the host script drives stop→restore→start→/readyz. Since this overwrites the LIVE demo from a set that WAS just taken of the same demo, it is idempotent (no data change) — but only do this if comfortable; the row-count proof in Step 6 already proves restore correctness without touching live. If run, re-assert /me 5 modules after.

  • [ ] Step 9 — Restore all state + demo-intact assert. Reset settings to defaults: PUT {enabled:true, interval_hours:24, retention_count:7}. Take the backup profile down (single-service): docker compose -f deploy/docker-compose.yml stop obscura-backup then docker compose -f deploy/docker-compose.yml rm -f obscura-backup (removes the profile container; NEVER down -v). Delete the test sets: rm -rf deploy/backups/* (host side) or via the sidecar before removing it. Confirm all default services running/healthy (docker compose ps). GET /api/v1/me → 5 modules intact. Report which assertions passed, the observed metric value, both floor 400s, and the restore row-count equalities. No commit unless an e2e-driven fix was needed (then commit it explicitly with its own message).


Self-review (spec coverage · placeholder scan · type consistency)

Spec coverage — every spec surface has a task:
- Sidecar (Dockerfile.backup + backup.sh + entrypoint.sh poll loop + compose obscura-backup profile + app :ro mount) → T2.
- backup_settings singleton migration + floors + run_requested_atT1 (table/domain/adapters) + T3 (API).
- Admin API GET/PUT settings, GET status, POST runT3.
- App-side FSCatalog (metadata-only, graceful-empty) → T1 (adapter) + T3 (endpoint).
- Stale-backup metric obscura_backup_last_success_timestamp_secondsT4.
- restore.sh (confirm gate) + docs/BACKUP.md runbook → T5.
- Admin → Backups tab (settings + run-now + read-only set history + restore note) + en/id → T6.
- e2e (backup/settings/floor/restore-into-throwaway/retention/metric/demo-safe) → T7.
- Out-of-scope (in-app restore, WAL/PITR, at-rest encryption, off-site replication, mailpit/keycloak/ldap backup, incremental) → not built, matching the spec.

Placeholder scan: no TODO/.../FIXME in the task code. The one deliberate ordering note is T2's restore.sh COPY dependency on T5 — flagged with a concrete stub instruction so the build never breaks.

Type consistency across tasks: the on-disk status.json field names (ts/result/db_bytes/blob_count/blob_bytes/duration_seconds/error) written by backup.sh (T2) match backupdomain.SetStatus JSON tags (T1), the OpenAPI BackupSet schema (T3), and the BackupsTab table (T6). Settings field names (enabled/interval_hours/retention_count) match across the migration (T1), backupSettingsBody/BackupSettings schema (T3), and the tab (T6). Floors (FloorIntervalHours=1/FloorRetentionCount=1, T1) are echoed by the API floors object (T3), the sidecar's shell floor clamps (T2), and the UI min= (T6) — one source of truth in backupdomain. The interface methods (Load/Save/RequestRun, Read, LatestSuccessUnix) are declared in httpapi (T3)/collector (T4) exactly as implemented by the adapters (T1).

Sequencing note (restated): T1 before T2 (the sidecar's psql read needs the backup_settings table; also written tolerant of a missing table). T3 before T4's metrics.Register(...) line only in that both reference backupCatalog — either constructs it; the plan constructs it in T3 and T4 just registers. T5's restore.sh is COPYed by T2's Dockerfile — land the stub in T2 or reorder T5's file creation before the T7 build.