State-in-backend: instance state lives encrypted in the storage backend, not on local disk #138

Closed
opened 2026-08-07 21:01:42 +00:00 by Cordy · 3 comments
Owner

Decided with Nikola (2026-08-07). This is the v0.6 milestone's core issue — design first, then implementation.

Motivation

Dogfooding v0.4.x surfaced that every runtime state file dies with /data. On the dogfood, /data was an emptyDir, so each redeploy silently reset: license-state.json (the "my license disappears after updates" report), settings.json (incl. peeringEnabled), shares.json (every share link died), holds.json (legal holds silently released by a version bump — the worst item for our compliance story), peering.json (trust state incl. live peer credentials), app-passwords.json, local-users.json. The dogfood is patched with a PVC (homelab-config cairn-enc-data), but every customer who runs the container without persistence has the identical failure mode waiting.

The deep fix: stop keeping instance state on local disk. Precedent is MinIO, which stores its whole configuration and IAM in a hidden .minio.sys prefix inside the object store it serves — the server binary is stateless, and pointing a fresh server at an existing volume resurrects the instance completely.

Design

Each atomic-JSON store reads/writes .cairn-state/<name>.json inside the configured storage backend instead of /data/.cairn/<name>.json:

  • S3 driver: objects under a reserved .cairn-state/ prefix in the bucket. The prefix must be invisible to and unreachable from all user-facing surfaces (WebDAV, UI listing, shares, peering delivery) — same treatment as other internal paths.
  • posix/NFS driver: .cairn-state/ under the data root. These deployments already persist the data root, so they get durability for free and keep true rename-based atomicity.
  • Encrypted at rest, non-negotiable: state is encrypted to the instance's recovery/instance identity via the existing age machinery. peering.json holds live credentials (keys peers issued us) and local-users.json holds verifiers; a bucket compromise must not yield either in the clear.

The config/state line becomes exact: config.json (+ its secret) answers "where is my data, how do I unlock it, who authenticates my users" — backend connection, encryption/recovery identity, IdP. Everything else is state and lives with the data. Corollary: the backend connection itself can never move into runtime settings (the pointer to storage can't live in the storage), which settles the "connect S3 from the settings panel" question — that surface is a read-only card plus, later, a first-run wizard and a supervised migration tool, never a live toggle.

Stays local (genuine scratch): tus upload spool, peering blob staging (peer-blobs). Already memory-only and staying that way: challenges, spent tokens, in-flight transfer records.

Questions the design pass must answer

  1. Atomicity on S3. Current stores do write-temp-then-rename; S3 has no rename. A single whole-object PUT is atomic per object, which suffices for whole-file JSON — but confirm Garage's semantics, and decide whether we need read-after-write verification or a version/etag guard against lost updates.
  2. Boot ordering. Storage must initialize before settings/license/peering load — today's order is roughly the reverse. Map the new dependency graph in main.go, including the failure mode "backend unreachable at boot" (serve degraded? refuse to start? retry loop with health probe implications?).
  3. Write-through caching. State reads happen on every request path today via in-memory structs; keep that. Writes are rare (admin actions) — write-through to the backend, fail the mutating request if the PUT fails (no fire-and-forget for holds or peering).
  4. Migration. On boot: local state present + backend state absent → upload once, then prefer backend; local copy retained read-only as a fallback for one release cycle. Explicitly define the conflict case (both present, differing) — newest-wins is wrong for holds; propose refuse-and-tell-operator.
  5. Two-writer hazard. Single replica + Recreate is the supported topology, but document what happens if two instances share a bucket (they must — peering already assumes distinct buckets per instance? verify) and consider a lightweight instance-lock object with TTL.
  6. License re-activation fold-in. With state in the backend the license survives updates anyway; still add boot re-activation from config/env key (CAIRN_LICENSE_KEY already wins per config.go) for the fresh-instance/DR path — it becomes a small corner of this issue instead of its own feature.
  7. Backup story. After this, backing up the bucket backs up the whole instance. Update docs/handbook/deployment.md accordingly, including the sharp-edge table of what was previously local-only.

Acceptance

  • Kill the pod, delete its volume entirely, restart against the same bucket + config: license, settings, shares, holds, peering, app passwords, local users all intact.
  • Full update cycle (tag → deploy) on an instance with no persistent /data loses nothing.
  • posix-driver instance behaves identically before/after (state just moved under the data root).
  • Raw state objects in the bucket are ciphertext; sovereignty check (pull object out of Garage, age -d with recovery key) works for state exactly as it does for file data.
  • User-facing surfaces cannot list, read, share, or deliver into .cairn-state/.
Decided with Nikola (2026-08-07). This is the v0.6 milestone's core issue — design first, then implementation. ## Motivation Dogfooding v0.4.x surfaced that **every runtime state file dies with `/data`**. On the dogfood, `/data` was an `emptyDir`, so each redeploy silently reset: `license-state.json` (the "my license disappears after updates" report), `settings.json` (incl. `peeringEnabled`), `shares.json` (**every share link died**), `holds.json` (**legal holds silently released by a version bump** — the worst item for our compliance story), `peering.json` (trust state incl. live peer credentials), `app-passwords.json`, `local-users.json`. The dogfood is patched with a PVC (homelab-config `cairn-enc-data`), but every customer who runs the container without persistence has the identical failure mode waiting. The deep fix: **stop keeping instance state on local disk.** Precedent is MinIO, which stores its whole configuration and IAM in a hidden `.minio.sys` prefix inside the object store it serves — the server binary is stateless, and pointing a fresh server at an existing volume resurrects the instance completely. ## Design Each atomic-JSON store reads/writes `.cairn-state/<name>.json` **inside the configured storage backend** instead of `/data/.cairn/<name>.json`: - **S3 driver:** objects under a reserved `.cairn-state/` prefix in the bucket. The prefix must be invisible to and unreachable from all user-facing surfaces (WebDAV, UI listing, shares, peering delivery) — same treatment as other internal paths. - **posix/NFS driver:** `.cairn-state/` under the data root. These deployments already persist the data root, so they get durability for free and keep true rename-based atomicity. - **Encrypted at rest, non-negotiable:** state is encrypted to the instance's recovery/instance identity via the existing age machinery. `peering.json` holds live credentials (keys peers issued us) and `local-users.json` holds verifiers; a bucket compromise must not yield either in the clear. **The config/state line becomes exact:** `config.json` (+ its secret) answers "where is my data, how do I unlock it, who authenticates my users" — backend connection, encryption/recovery identity, IdP. Everything else is state and lives with the data. Corollary: the backend connection itself can never move into runtime settings (the pointer to storage can't live in the storage), which settles the "connect S3 from the settings panel" question — that surface is a read-only card plus, later, a first-run wizard and a supervised migration tool, never a live toggle. **Stays local (genuine scratch):** tus upload spool, peering blob staging (`peer-blobs`). Already memory-only and staying that way: challenges, spent tokens, in-flight transfer records. ## Questions the design pass must answer 1. **Atomicity on S3.** Current stores do write-temp-then-rename; S3 has no rename. A single whole-object PUT is atomic per object, which suffices for whole-file JSON — but confirm Garage's semantics, and decide whether we need read-after-write verification or a version/etag guard against lost updates. 2. **Boot ordering.** Storage must initialize before settings/license/peering load — today's order is roughly the reverse. Map the new dependency graph in `main.go`, including the failure mode "backend unreachable at boot" (serve degraded? refuse to start? retry loop with health probe implications?). 3. **Write-through caching.** State reads happen on every request path today via in-memory structs; keep that. Writes are rare (admin actions) — write-through to the backend, fail the mutating request if the PUT fails (no fire-and-forget for holds or peering). 4. **Migration.** On boot: local state present + backend state absent → upload once, then prefer backend; local copy retained read-only as a fallback for one release cycle. Explicitly define the conflict case (both present, differing) — newest-wins is wrong for holds; propose refuse-and-tell-operator. 5. **Two-writer hazard.** Single replica + Recreate is the supported topology, but document what happens if two instances share a bucket (they must — peering already assumes distinct buckets per instance? verify) and consider a lightweight instance-lock object with TTL. 6. **License re-activation fold-in.** With state in the backend the license survives updates anyway; still add boot re-activation from config/env key (`CAIRN_LICENSE_KEY` already wins per config.go) for the fresh-instance/DR path — it becomes a small corner of this issue instead of its own feature. 7. **Backup story.** After this, backing up the bucket backs up the whole instance. Update `docs/handbook/deployment.md` accordingly, including the sharp-edge table of what was previously local-only. ## Acceptance - Kill the pod, delete its volume entirely, restart against the same bucket + config: license, settings, shares, holds, peering, app passwords, local users all intact. - Full update cycle (tag → deploy) on an instance with **no** persistent `/data` loses nothing. - posix-driver instance behaves identically before/after (state just moved under the data root). - Raw state objects in the bucket are ciphertext; sovereignty check (pull object out of Garage, `age -d` with recovery key) works for state exactly as it does for file data. - User-facing surfaces cannot list, read, share, or deliver into `.cairn-state/`.
Author
Owner

Milestone scaffolded 2026-08-09 (v0.5 closed the same day — v0.5.0 is live on the dogfood). This issue's seven design questions are now an issue chain, v0.5-style: a gating ADR first, everything else hangs off it. This issue stays open as the milestone umbrella and closes last, with #159.

# Issue Answers question(s) Depends on
#152 ADR: statestore abstraction — interface, local + backend impls, encryption envelope, S3 atomicity, the config/state line, what stays local 1, 3, plus the line — (gates all)
#153 Port the nine stores (notify-seen joined the list since this brief) + boot-order inversion + write-through 2, 3 #152
#154 Disk→backend migration, refuse-on-conflict (holds must never auto-merge) 4 #153
#155 .cairn-state/ invisible/unreachable from every user surface incl. unscoped admins (new — implied) #152
#156 Instance lock — advisory two-writer tripwire, honest about its limits 5 #152
#157 License re-activation at boot from config/env key — independent, can ship first 6
#158 Docs: backup story, sharp-edge table, ARCHITECTURE § — write last, from fact (#124 pattern) 7 #159 passing
#159 Acceptance: the delete-the-volume dogfood — closes the milestone all #152–#157

Suggested order: #157 (small, independent, customer-facing win immediately) → #152#153#155#156#154#159#158.

**Milestone scaffolded 2026-08-09** (v0.5 closed the same day — v0.5.0 is live on the dogfood). This issue's seven design questions are now an issue chain, v0.5-style: a gating ADR first, everything else hangs off it. This issue stays open as the milestone umbrella and closes last, with #159. | # | Issue | Answers question(s) | Depends on | |---|---|---|---| | #152 | **ADR: statestore abstraction** — interface, local + backend impls, encryption envelope, S3 atomicity, the config/state line, what stays local | 1, 3, plus the line | — (gates all) | | #153 | Port the **nine** stores (notify-seen joined the list since this brief) + boot-order inversion + write-through | 2, 3 | #152 | | #154 | Disk→backend **migration**, refuse-on-conflict (holds must never auto-merge) | 4 | #153 | | #155 | `.cairn-state/` **invisible/unreachable** from every user surface incl. unscoped admins | (new — implied) | #152 | | #156 | **Instance lock** — advisory two-writer tripwire, honest about its limits | 5 | #152 | | #157 | **License re-activation at boot** from config/env key — independent, can ship first | 6 | — | | #158 | **Docs**: backup story, sharp-edge table, ARCHITECTURE § — write last, from fact (#124 pattern) | 7 | #159 passing | | #159 | **Acceptance: the delete-the-volume dogfood** — closes the milestone | all | #152–#157 | Suggested order: #157 (small, independent, customer-facing win immediately) → #152 → #153 → #155 ∥ #156 → #154 → #159 → #158.
Author
Owner

Scaffold amended 2026-08-09: all milestone-wide design decisions are ratified in one pass — the ADR lives on #152 (D1–D11). New in scope: #160 — storage connect from the WebUI (bootstrap layer, storage.driver: "setup" mode, wizard with probe→persist→graceful-restart), which amends the config/state line into the three-layer model: the pointer to your data lives locally; everything it points to lives with the data.

Updated chain: #157 (independent, first) → #152 (skeleton; design done) → #153#155#156#154#160#159 (acceptance, now also covers setup-mode + wizard) → #158 (docs, incl. bootstrap/setup-mode sharp edges). With decisions pre-made, implementation is planned as three large sessions: (1) #157 + #152, (2) #153 + #154, (3) #155 + #156 + #160 — then acceptance and docs.

**Scaffold amended 2026-08-09:** all milestone-wide design decisions are ratified in one pass — the ADR lives on #152 (D1–D11). New in scope: **#160 — storage connect from the WebUI** (bootstrap layer, `storage.driver: "setup"` mode, wizard with probe→persist→graceful-restart), which amends the config/state line into the three-layer model: *the pointer to your data lives locally; everything it points to lives with the data.* Updated chain: #157 (independent, first) → #152 (skeleton; design done) → #153 → #155 ∥ #156 → #154 → #160 → #159 (acceptance, now also covers setup-mode + wizard) → #158 (docs, incl. bootstrap/setup-mode sharp edges). With decisions pre-made, implementation is planned as three large sessions: (1) #157 + #152, (2) #153 + #154, (3) #155 + #156 + #160 — then acceptance and docs.
Author
Owner

Umbrella closed — every acceptance criterion executed, not just met.

  1. The definitive test ran for real (#159, 2026-08-10): deployment and PVC deleted outright, recreated from Git, and the instance came back whole from the bucket — license, settings, share links, holds, peering trust (keys + peers + toggle), app passwords, notification watermarks, user files.
  2. Update cycle with no persistence: v0.5.2→…→v0.5.7 rolled through six deploys during the milestone, three of them after the volume swap, losing nothing.
  3. posix parity: the throwaway wizard instance ran the full first-run flow on the posix driver with state under the data root; the Local store is byte-identical to pre-v0.6 files (zero-migration path, unit-proven).
  4. Sovereignty: Nikola pulled .cairn-state/settings.json.age from Garage and opened it offline with the recovery key; a wrong key fails. Same drill as file data, now documented next to it.
  5. Guard rails fired in anger: the migration refuse-path executed twice during the v0.5.2/v0.5.3 crashloops as clean refusals with zero partial state; .cairn-state verified invisible from the live admin UI; the lock object heartbeats in the bucket.

Design questions from this issue, all settled and recorded in the #152 ADR (D1–D11): whole-object PUT without CAS (D2), boot ordering storage-first with refuse-on-unreachable (D5), synchronous write-through (D6), refuse-on-conflict migration (D7), the advisory lock adopted with honest limits (D8), license re-activation folded in (#157/D10), and the backup story rewritten (#158). Beyond the original scope, the milestone also delivered storage-connect-from-the-browser (#160: bootstrap layer, setup mode, wizard) and two rounds of UX hardening the live walkthrough surfaced (#166, #168).

Shipped as v0.5.1–v0.5.7; milestone closes as v0.6.0. The config/state line, final form: the pointer to your data lives locally; everything it points to lives with the data.

**Umbrella closed — every acceptance criterion executed, not just met.** 1. **The definitive test** ran for real (#159, 2026-08-10): deployment and PVC deleted outright, recreated from Git, and the instance came back whole from the bucket — license, settings, share links, holds, peering trust (keys + peers + toggle), app passwords, notification watermarks, user files. 2. **Update cycle with no persistence**: v0.5.2→…→v0.5.7 rolled through six deploys during the milestone, three of them after the volume swap, losing nothing. 3. **posix parity**: the throwaway wizard instance ran the full first-run flow on the posix driver with state under the data root; the `Local` store is byte-identical to pre-v0.6 files (zero-migration path, unit-proven). 4. **Sovereignty**: Nikola pulled `.cairn-state/settings.json.age` from Garage and opened it offline with the recovery key; a wrong key fails. Same drill as file data, now documented next to it. 5. **Guard rails fired in anger**: the migration refuse-path executed twice during the v0.5.2/v0.5.3 crashloops as clean refusals with zero partial state; `.cairn-state` verified invisible from the live admin UI; the lock object heartbeats in the bucket. Design questions from this issue, all settled and recorded in the #152 ADR (D1–D11): whole-object PUT without CAS (D2), boot ordering storage-first with refuse-on-unreachable (D5), synchronous write-through (D6), refuse-on-conflict migration (D7), the advisory lock adopted with honest limits (D8), license re-activation folded in (#157/D10), and the backup story rewritten (#158). Beyond the original scope, the milestone also delivered storage-connect-from-the-browser (#160: bootstrap layer, setup mode, wizard) and two rounds of UX hardening the live walkthrough surfaced (#166, #168). Shipped as v0.5.1–v0.5.7; milestone closes as **v0.6.0**. The config/state line, final form: *the pointer to your data lives locally; everything it points to lives with the data.*
Cordy closed this issue 2026-08-10 00:26:04 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#138
No description provided.