Skip to content

Operations runbooks

Step-by-step procedures for operating a standalone Graphorin server: backup, restore, upgrade, rollback and key rotation. Each runbook uses only shipped commands, states its preconditions, and says what proves it in CI. Scope: a single-node server (the supported deployment shape today - see Road to 1.0 for what HA/scale-out evidence is still owed).

The Storage page carries the command safety matrix (read-only vs write-lock-contending vs requires-a-stopped-server); this page assumes it.

What is on disk

ArtifactDefault locationNotes
Main databasestorage.path (./.graphorin/data.db; /data/data.db in the container)Plus WAL sidecars data.db-wal / data.db-shm while a writer is up. Holds memory, sessions, checkpoints, triggers, auth tokens, idempotency state.
Audit logaudit.path (defaults to audit.db next to the main DB)Always encrypted, separate hash chain.
Secretskeyring, or the encrypted-file bundle (~/.graphorin/secrets.enc / mounted files)The bundle is AES-256-GCM at rest - safe to copy as a file.
Config--config path (/etc/graphorin/config.json in the container)Plain file; keep it in your config management, not in backups of the data dir.

Backup

graphorin storage backup has two honest modes, selected by the config's storage.encryption.enabled:

  • Plaintext store: an online, page-level backup - safe under a live writer (the server keeps running) and preserves rowids so the FTS5 mapping survives.
  • Encrypted store: a consistent stopped-server byte copy. The cipher driver cannot key either side of an online page-level transfer, so the command checkpoints the WAL, proves no other holder is live (refusing with a live-writer error otherwise), byte-copies the file and verifies the copy's cipher integrity. The copy stays encrypted with the same key.

Either way it is the only supported backup - never use VACUUM INTO (it renumbers rowids and corrupts the FTS mapping on restore).

bash
# Plaintext store - run on a schedule, the server may stay up.
# Encrypted store - run in the maintenance window with the server stopped.
graphorin storage backup /backups/data-$(date +%F).db --config /etc/graphorin/config.json

# Retention for the backups the server knows about:
graphorin storage cleanup-backups --config /etc/graphorin/config.json

# Reclaim free pages periodically (write-lock-contending, prefer quiet hours):
graphorin storage compact --config /etc/graphorin/config.json

The audit log is a separate database with its own passphrase. Two supported options:

bash
# Online, append-friendly archival (JSONL, resumable by sequence):
graphorin audit export --to /backups/audit-$(date +%F).jsonl --config /etc/graphorin/config.json

or a plain file copy of audit.db while the server is stopped. For the secrets bundle, a plain file copy is enough - it is encrypted at rest.

Treat data.db + audit.db + the secrets bundle as one backup set: restoring them from different points in time works, but audit references and token state may skew across the gap.

Restore

Preconditions: a backup file produced by storage backup; the server stopped.

  1. Stop the server.
  2. Move the damaged database AND its sidecars out of the way: data.db, data.db-wal, data.db-shm (stale sidecars from a different database generation must not be replayed into the restored file).
  3. Copy the backup into place as storage.path and restore tight permissions: chmod 0600 data.db.
  4. Start the server. Migrations are idempotent and checksummed - a backup taken on an older patch/minor upgrades itself on boot.
  5. Verify: /v1/health answers ok, and graphorin doctor --all --config ... is clean.

Caveats:

  • Backups are point-in-time. Erasure/deletion requests honored after the backup was taken must be repeated after a restore.
  • Raw auth tokens minted after the backup point no longer verify (their hashes are not in the restored DB); tokens minted before it work again. graphorin token list shows what the restored state believes.
  • A fresh audit.db starts a new chain if you do not restore the old one alongside; the old chain stays independently verifiable from its own backup.

The weekly Docker smoke workflow runs this drill end-to-end: boots the shipped image (encrypted store), mints a token, stops the server, takes the stopped-server backup, destroys the container AND the data volume, restores the backup into a fresh volume, reboots, and asserts the token survived.

Upgrade

Graphorin releases are lockstep: upgrade every @graphorin/* package to the same version at once.

  1. Read the target version's section in Migration (pre-1.0) - pre-1.0 minors may carry breaking changes; patches never do.
  2. Take a backup first (see above). The schema is forward-only, so this backup IS your rollback path.
  3. Install the new lockstep set (pin exact versions in your manifest/lockfile; for the container image, rebuild from the new tag).
  4. Apply migrations offline: graphorin migrate --config ... on the stopped server, or let the server migrate itself on first boot - both run the same atomic, idempotent runner. Read-only CLI commands against a newer-schema database refuse rather than auto-upgrade, so a half-rolled fleet fails loud, not quiet.
  5. Start, then verify /v1/health and graphorin doctor --all.

Rollback

There is deliberately no schema downgrade. Rolling back a release:

  1. Stop the server.
  2. Reinstall the previous lockstep version set.
  3. Restore the pre-upgrade backup (runbook above) - this is why step 2 of Upgrade is not optional.
  4. Start and verify.

Data written between the upgrade and the rollback lives only in the rolled-back-from database; session exports taken from it remain readable by the older release within the N-2 export compatibility band.

Key rotation

Four distinct keys, four runbooks - they do not substitute for each other:

Database passphrase (encrypted store)

bash
# Requires a stopped server; passphrases travel as SecretRef URIs, never argv:
graphorin storage rekey \
  --old-passphrase-from file:/run/secrets/graphorin/db-passphrase \
  --new-passphrase-from file:/run/secrets/graphorin/db-passphrase-next \
  --config /etc/graphorin/config.json

Then update the config's storage.encryption.passphraseRef source to serve the new value and start the server. The rekey verifies cipher integrity before reporting success.

Server pepper (token hashing)

Only token hashes are stored, keyed by the pepper - a new pepper invalidates every existing token, and nothing can re-hash them. The order matters:

bash
# 1. Write the new pepper into the secrets source the config points at
#    (auth.pepperRef), e.g.:
openssl rand -hex 32 | graphorin secrets set graphorin_server_pepper --from-stdin

# 2. Re-issue every active token under the new pepper:
graphorin token rekey --config /etc/graphorin/config.json

# 3. Redistribute the freshly printed raw tokens to clients.

A live server's verifier cache honors old tokens for up to ~60 s after rotation - plan the redistribution window accordingly.

Secrets bundle passphrase (encrypted-file store)

bash
# Re-encrypts the whole bundle (fresh KDF salt), values unchanged:
GRAPHORIN_MASTER_PASSPHRASE='<current>' graphorin secrets rekey \
  --secrets-source encrypted-file \
  --new-passphrase-from env:GRAPHORIN_NEW_MASTER_PASSPHRASE

Afterwards start supplying the new value as GRAPHORIN_MASTER_PASSPHRASE. Sources without a bundle passphrase (keyring, env, memory) answer exit code 2 (unsupported).

Individual secret values

bash
graphorin secrets rotate <key> --from-stdin

Same store write as set, surfaced separately so audit logs distinguish a rotation from an initial write. Rotate the deployment pepper and provider API keys on your normal credential schedule.

Scaling model

SQLite is a single-writer store, and the architecture is honest about it: exactly one server process owns the database file. WAL mode keeps concurrent reads cheap inside that process; a second process on the same file is refused by the write lock, not silently corrupted.

What that means operationally:

  • Scale up, not out. More CPU/RAM on the one node; the k8s template's replicas: 1 + strategy: Recreate is a design statement, not a placeholder.
  • Scale by partition, not by replica. Multiple tenants or workloads = multiple server instances, each with its own volume, secrets, and tokens. Nothing is shared, so nothing needs consensus.
  • Availability = fast restore, not failover. The backup/restore runbook above plus the crash-resume behaviour below are the availability story today; crash recovery time is budgeted and measured weekly (see What CI proves). A shared-backend HA topology would need a different store backend and is tracked on the road to 1.0.
  • Agent instances are single-flight. One run at a time per registered instance (409 agent-busy otherwise) - deploy an instance pool sized to your target concurrency (see the production golden path).

Load and soak SLOs

The weekly soak workflow (soak.yml) drives sustained, paced load through the full server turn path - auth, agent runtime, provider streaming, run tracking, SQLite - using a deterministic stub provider, and fails the run when any published budget is violated:

SLOBudget
Transport errors / 5xx / any non-200zero
Latency p95 (full POST /v1/agents/:id/run turn, loopback, stub provider)< 1000 ms
Server RSS peak< 1 GB
Memory growthlast-quarter mean RSS < 2x first-quarter mean + 64 MB

The budgets are deliberately generous - shared CI runners are noisy neighbours; the gate exists to catch order-of-magnitude regressions and leaks, not 10% drift. The driver paces to ~300 requests/second (still far above real LLM-bound traffic) rather than hammering at max throughput: run-tracker bookkeeping retains terminal records for five minutes by design, so memory scales with request RATE, and an unpaced stub can push it anywhere.

Published soak runs

Every run below is a public GitHub Actions run of soak.yml on an ubuntu-latest runner (2 vCPU class), reproducible via Actions -> "Server soak (stub provider)" -> Run workflow with the listed inputs. The weekly schedule runs the 600 s variant; the 3600 s row is the long-soak envelope, re-dispatched after operationally significant changes rather than weekly.

DateDurationConcurrency / target rpsRequests (all 200)p50 / p95 / p99RSS first-quarter -> peakVerdict
2026-07-223600 s8 / 3001,101,4862.5 / 4.1 / 6.1 ms234 -> 290 MBPASS
2026-07-22300 s8 / 30091,5342.6 / 4.3 / 6.4 ms156 -> 225 MBPASS

The hour-long run is the long-soak envelope: over a million full agent turns with zero non-200s, flat tail latencies, and last-quarter mean RSS (280 MB) within 20% of the first quarter - the run-tracker's five-minute retention window plateaus exactly as designed instead of growing with total request count.

Real-provider soak

The stub rows above prove the server; this run proves the same turn path against a LIVE cloud provider - TLS, streaming, retry middleware, real latency and error behaviour - paced at realistic LLM-bound traffic. It is maintainer-gated (costs real money, needs OPENAI_API_KEY), runs from a workstation rather than CI (the repository deliberately carries no provider secret), and reproduces with scripts/soak-real.sh; the compose module is .github/smoke/soak-real.app.mjs (agent pool over the openai-compatible adapter wrapped in withRetry, output capped at 128 tokens/turn).

DateSubjectDurationTarget rpsRequests (all 200)p50 / p95 / p99RSS quarters -> peakVerdict
2026-07-22gpt-4.1-nano (api.openai.com)3600 s (+ drain)26,322831 / 1,786 / 5,347 ms94 -> 97 -> 132 MBPASS

SLO envelope for this variant: zero non-200/transport errors, p95 < 20 s (cloud latency, not the stub's 1 s), same RSS ceiling and growth bound. One turn peaked at ~16.7 min: the retry middleware absorbed a provider-side stall and the request still returned 200 with every concurrent request unaffected (p99 stayed at 5.3 s) - which is exactly the behaviour the middleware exists to prove. Achieved rate was 1.5 rps against the 2 rps target: pacing slots are skipped, not queued, while a worker waits out a slow turn.

What CI proves

  • Weekly Docker smoke: image build, boot with the shipped config (encrypted store + token auth), online backup, full destroy-and-restore drill, health + token survival - plus an image vulnerability gate on fixable high/critical findings.
  • Crash-resume drill (same workflow): a workflow run parked on a durable 15 s timer survives docker kill (SIGKILL - no shutdown hooks) and completes after restart with NO operator action; the timer daemon's post-boot sweep fires the due timer read back from SQLite. Recovery time is measured and gated on every run, both clocks starting at the SIGKILL (so container teardown + boot are included): SIGKILL to /v1/health OK under 60 s, SIGKILL to the parked run completed under 150 s (measured 2 s / 14 s on a shared ubuntu-latest runner, 2026-07-23, run 30005490068). Budgets are generous for the same shared-runner reason as the soak SLOs; each run's measured numbers land in its step summary.
  • Weekly soak: the SLO table above, enforced.
  • Per-PR template validation: the Kubernetes manifest under kubeconform -strict; the systemd unit under systemd-analyze verify (any warning fails) plus an offline security-exposure score gated below 5.
  • Package test suites: online backup mode/permissions, encrypt + rekey + swap live-writer guards, token rekey semantics, secrets bundle rekey round-trip (old passphrase must fail afterwards), migration idempotence + checksum tamper refusal, export schema-band enforcement, multi-process checkpoint durability.

Not covered yet

HA topologies with automatic failover, rolling upgrades across replicas, and chaos/failure-injection beyond the SIGKILL drill are not yet proven - they are the operational tail tracked on the road to 1.0. Until then, size a single node with Performance & scale and treat the restore runbook as the availability story.